From 0ca69e6f575e846c6b8b9c77ba40674fc110cfd0 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 20 Feb 2026 09:49:02 +0100 Subject: [PATCH] invoices/sql: replace catch-all GetInvoice with indexed lookups The GetInvoice query used an OR IS NULL pattern for each filter parameter: WHERE (i.hash = $1 OR $1 IS NULL) AND (i.payment_addr = $2 OR $2 IS NULL) SQLite's query planner decides on an execution plan at prepare time, before seeing any parameter values. Because either condition can be trivially true when its parameter is NULL, the planner conservatively falls back to a full table scan rather than using the unique indexes on hash and payment_addr. This caused every invoice lookup and update on the hot path (HTLC settlement) to scan the entire invoices table. Replace the single catch-all query with three dedicated queries, each using a direct equality on a uniquely constrained column: - GetInvoiceByHash: WHERE hash = $1 - GetInvoiceByAddr: WHERE payment_addr = $1 (new, covers AMP path) - GetInvoiceBySetID: existing, unchanged Update getInvoiceByRef to route to the appropriate query based on which fields are present in the InvoiceRef. When both hash and payment address are provided, we look up by hash and then verify the returned invoice's payment address matches. GetInvoice is now unused and removed from the SQLInvoiceQueries interface. --- invoices/sql_store.go | 119 ++++++++++++++++---------------- sqldb/sqlc/invoices.sql.go | 32 +++++++++ sqldb/sqlc/querier.go | 1 + sqldb/sqlc/queries/invoices.sql | 5 ++ 4 files changed, 99 insertions(+), 58 deletions(-) diff --git a/invoices/sql_store.go b/invoices/sql_store.go index a31d354fc..0628a5d54 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 + // 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 + } + } + + return invoice, nil } - // 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 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 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 has a set ID we'll fetch the invoice which has the - // corresponding AMP sub invoice. + // If only the set ID is given, look up via the AMP sub-invoice index. if ref.SetID() != nil { - params.SetID = ref.SetID()[:] + rows, err := db.GetInvoiceBySetID(ctx, ref.SetID()[:]) + if err != nil { + return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+ + "invoice: %w", err) + } + + if len(rows) == 0 { + return sqlc.Invoice{}, ErrInvoiceNotFound + } + + if len(rows) > 1 { + return sqlc.Invoice{}, fmt.Errorf("ambiguous "+ + "invoice ref: set_id=%x matches %d invoices", + ref.SetID(), len(rows)) + } + + return rows[0], 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) - } - - switch { - case 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)) - - case err != nil: - return sqlc.Invoice{}, fmt.Errorf("unable to fetch invoice: %w", - err) - } - - 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 911c4e1d1..99e973924 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 947237300..5b9c7d5ab 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 f9b92388d..22e5cd08e 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