invoices/sql: replace catch-all GetInvoice with indexed lookups
What changed, and why it matters
This commit fixes a database performance bug in LND's invoice handling. The old code used a flexible query that could match by payment hash, payment address, or neither, which made the database scan the entire invoices table for every lookup. The new code uses specific, indexed queries so lookups are fast. The change also adds a check that prevents a caller from providing a payment hash and payment address that point to different invoices. There is no direct evidence in the commit that this was a security vulnerability, but the performance issue could contribute to denial-of-service under load.
Treat as a performance and hardening improvement rather than a critical security patch. Operators running LND with the SQL invoice backend should upgrade to benefit from faster HTLC settlement and reduced database load. Reviewers should confirm the new equivocation check does not break legitimate AMP or keysend invoice workflows.
Security signals we found
Full table scan on hot path (HTLC settlement) creates a potential denial-of-service vector via resource exhaustion
Added equivocation check when both payment hash and payment address are provided
No explicit security framing in commit message or diff
Performance fix rather than a memory-safety or cryptographic bug fix
Evidence from the diff
The patch replaces the catch-all GetInvoice SQL query, which used OR IS NULL predicates on hash, payment_addr, and set_id, with dedicated indexed queries: GetInvoiceByHash, GetInvoiceByAddr, and GetInvoiceBySetID. SQLite’s planner prepares the OR IS NULL query before parameter values are known, so it conservatively performs a full table scan rather than using unique indexes. This caused invoice lookups and updates on the HTLC settlement hot path to scan the whole invoices table. The Go routing logic in getInvoiceByRef now selects the appropriate indexed query based on which InvoiceRef fields are populated. When both hash and payment address are supplied, it looks up by hash and verifies the returned invoice’s payment address matches, returning ErrInvRefEquivocation on mismatch. The old GetInvoice query remains in generated code but is removed from the SQLInvoiceQueries interface.
Changed components
invoices/sql_store.gosqldb/sqlc/invoices.sql.gosqldb/sqlc/querier.gosqldb/sqlc/queries/invoices.sqlInspect captured patch +96 / −55
diff --git a/invoices/sql_store.go b/invoices/sql_store.go
index a31d354..0628a5d 100644
--- a/invoices/sql_store.go
+++ b/invoices/sql_store.go
@@ -1,6 +1,7 @@
package invoices
import (
+ "bytes"
"context"
"crypto/sha256"
"database/sql"
@@ -13,7 +14,6 @@ import (
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/lntypes"
- "github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/sqldb"
@@ -100,12 +100,12 @@ type SQLInvoiceQueries interface { //nolint:interfacebloat
FilterInvoicesReverse(ctx context.Context,
arg sqlc.FilterInvoicesReverseParams) ([]sqlc.Invoice, error)
- GetInvoice(ctx context.Context,
- arg sqlc.GetInvoiceParams) ([]sqlc.Invoice, error)
-
GetInvoiceByHash(ctx context.Context, hash []byte) (sqlc.Invoice,
error)
+ GetInvoiceByAddr(ctx context.Context,
+ paymentAddr []byte) (sqlc.Invoice, error)
+
GetInvoiceBySetID(ctx context.Context, setID []byte) ([]sqlc.Invoice,
error)
@@ -398,73 +398,76 @@ func getInvoiceByRef(ctx context.Context,
return sqlc.Invoice{}, ErrInvoiceNotFound
}
- // If the reference is a hash only, we can look up the invoice directly
- // by the payment hash which is faster.
- if ref.IsHashOnly() {
+ // If the reference contains a payment hash we can look up the invoice
+ // directly by hash using the unique index, avoiding a full table scan.
+ // The hash alone uniquely identifies any invoice so additional fields
+ // in the ref (payment address, set ID) are not needed for the lookup.
+ if ref.PayHash() != nil {
invoice, err := db.GetInvoiceByHash(ctx, ref.PayHash()[:])
if errors.Is(err, sql.ErrNoRows) {
return sqlc.Invoice{}, ErrInvoiceNotFound
}
+ if err != nil {
+ return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+
+ "invoice by hash: %w", err)
+ }
- return invoice, err
- }
-
- // Otherwise the reference may include more fields, so we'll need to
- // assemble the query parameters based on the fields that are set.
- var params sqlc.GetInvoiceParams
+ // If the ref also specifies a payment address, verify it
+ // matches the invoice found by hash. A mismatch means the ref
+ // is equivocating — the hash points to one invoice and the
+ // address points to another.
+ payAddr := ref.PayAddr()
+ if payAddr != nil && *payAddr != BlankPayAddr {
+ if !bytes.Equal(invoice.PaymentAddr, payAddr[:]) {
+ return sqlc.Invoice{}, ErrInvRefEquivocation
+ }
+ }
- if ref.PayHash() != nil {
- params.Hash = ref.PayHash()[:]
+ return invoice, nil
}
- // Newer invoices (0.11 and up) are indexed by payment address in
- // addition to payment hash, but pre 0.8 invoices do not have one at
- // all. Only allow lookups for payment address if it is not a blank
- // payment address, which is a special-cased value for legacy keysend
- // invoices.
- if ref.PayAddr() != nil && *ref.PayAddr() != BlankPayAddr {
- params.PaymentAddr = ref.PayAddr()[:]
- }
+ // If the reference contains a payment address (AMP payments), look up
+ // directly by payment address using the unique index.
+ //
+ // NOTE: Pre-0.8 invoices do not have a payment address, and blank
+ // payment addresses are a special case for legacy keysend invoices.
+ // Those are handled by the hash fast path above.
+ payAddr := ref.PayAddr()
+ if payAddr != nil && *payAddr != BlankPayAddr {
+ invoice, err := db.GetInvoiceByAddr(ctx, payAddr[:])
+ if errors.Is(err, sql.ErrNoRows) {
+ return sqlc.Invoice{}, ErrInvoiceNotFound
+ }
+ if err != nil {
+ return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+
+ "invoice by payment address: %w", err)
+ }
- // If the reference has a set ID we'll fetch the invoice which has the
- // corresponding AMP sub invoice.
- if ref.SetID() != nil {
- params.SetID = ref.SetID()[:]
+ return invoice, nil
}
- var (
- rows []sqlc.Invoice
- err error
- )
-
- // We need to split the query based on how we intend to look up the
- // invoice. If only the set ID is given then we want to have an exact
- // match on the set ID. If other fields are given, we want to match on
- // those fields and the set ID but with a less strict join condition.
- if params.Hash == nil && params.PaymentAddr == nil &&
- params.SetID != nil {
-
- rows, err = db.GetInvoiceBySetID(ctx, params.SetID)
- } else {
- rows, err = db.GetInvoice(ctx, params)
- }
+ // If only the set ID is given, look up via the AMP sub-invoice index.
+ if ref.SetID() != nil {
+ rows, err := db.GetInvoiceBySetID(ctx, ref.SetID()[:])
+ if err != nil {
+ return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+
+ "invoice: %w", err)
+ }
- switch {
- case len(rows) == 0:
- return sqlc.Invoice{}, ErrInvoiceNotFound
+ if len(rows) == 0 {
+ return sqlc.Invoice{}, ErrInvoiceNotFound
+ }
- case len(rows) > 1:
- // In case the reference is ambiguous, meaning it matches more
- // than one invoice, we'll return an error.
- return sqlc.Invoice{}, fmt.Errorf("ambiguous invoice ref: "+
- "%s: %s", ref.String(), lnutils.SpewLogClosure(rows))
+ if len(rows) > 1 {
+ return sqlc.Invoice{}, fmt.Errorf("ambiguous "+
+ "invoice ref: set_id=%x matches %d invoices",
+ ref.SetID(), len(rows))
+ }
- case err != nil:
- return sqlc.Invoice{}, fmt.Errorf("unable to fetch invoice: %w",
- err)
+ return rows[0], nil
}
- return rows[0], nil
+ return sqlc.Invoice{}, ErrInvoiceNotFound
}
// fetchInvoice fetches the common invoice data and the AMP state for the
diff --git a/sqldb/sqlc/invoices.sql.go b/sqldb/sqlc/invoices.sql.go
index 911c4e1..99e9739 100644
--- a/sqldb/sqlc/invoices.sql.go
+++ b/sqldb/sqlc/invoices.sql.go
@@ -476,6 +476,38 @@ func (q *Queries) GetInvoice(ctx context.Context, arg GetInvoiceParams) ([]Invoi
return items, nil
}
+const getInvoiceByAddr = `-- name: GetInvoiceByAddr :one
+SELECT i.id, i.hash, i.preimage, i.settle_index, i.settled_at, i.memo, i.amount_msat, i.cltv_delta, i.expiry, i.payment_addr, i.payment_request, i.payment_request_hash, i.state, i.amount_paid_msat, i.is_amp, i.is_hodl, i.is_keysend, i.created_at
+FROM invoices i
+WHERE i.payment_addr = $1
+`
+
+func (q *Queries) GetInvoiceByAddr(ctx context.Context, paymentAddr []byte) (Invoice, error) {
+ row := q.db.QueryRowContext(ctx, getInvoiceByAddr, paymentAddr)
+ var i Invoice
+ err := row.Scan(
+ &i.ID,
+ &i.Hash,
+ &i.Preimage,
+ &i.SettleIndex,
+ &i.SettledAt,
+ &i.Memo,
+ &i.AmountMsat,
+ &i.CltvDelta,
+ &i.Expiry,
+ &i.PaymentAddr,
+ &i.PaymentRequest,
+ &i.PaymentRequestHash,
+ &i.State,
+ &i.AmountPaidMsat,
+ &i.IsAmp,
+ &i.IsHodl,
+ &i.IsKeysend,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
const getInvoiceByHash = `-- name: GetInvoiceByHash :one
SELECT i.id, i.hash, i.preimage, i.settle_index, i.settled_at, i.memo, i.amount_msat, i.cltv_delta, i.expiry, i.payment_addr, i.payment_request, i.payment_request_hash, i.state, i.amount_paid_msat, i.is_amp, i.is_hodl, i.is_keysend, i.created_at
FROM invoices i
diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go
index 9472373..5b9c7d5 100644
--- a/sqldb/sqlc/querier.go
+++ b/sqldb/sqlc/querier.go
@@ -111,6 +111,7 @@ type Querier interface {
// from different invoices. It is the caller's responsibility to ensure that
// we bubble up an error in those cases.
GetInvoice(ctx context.Context, arg GetInvoiceParams) ([]Invoice, error)
+ GetInvoiceByAddr(ctx context.Context, paymentAddr []byte) (Invoice, error)
GetInvoiceByHash(ctx context.Context, hash []byte) (Invoice, error)
GetInvoiceBySetID(ctx context.Context, setID []byte) ([]Invoice, error)
GetInvoiceFeatures(ctx context.Context, invoiceID int64) ([]InvoiceFeature, error)
diff --git a/sqldb/sqlc/queries/invoices.sql b/sqldb/sqlc/queries/invoices.sql
index f9b9238..22e5cd0 100644
--- a/sqldb/sqlc/queries/invoices.sql
+++ b/sqldb/sqlc/queries/invoices.sql
@@ -59,6 +59,11 @@ SELECT i.*
FROM invoices i
WHERE i.hash = $1;
+-- name: GetInvoiceByAddr :one
+SELECT i.*
+FROM invoices i
+WHERE i.payment_addr = $1;
+
-- name: GetInvoiceBySetID :many
SELECT i.*
FROM invoices i
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.