From 93626822f757ba44e58721cb96e0b4fa31434c79 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 29 Sep 2025 16:56:14 +0200 Subject: [PATCH 01/78] lnd+paymentsdb: introduce harness for the payment sql backend We prepare the code for the sql payment backend. However no payment db interface method for the sql backend is implemented yet. This will be done in the following commits. They currently use the embedded KVStore to satify the build environment. --- config_builder.go | 59 +++++++++++++++++++++------------- config_prod.go | 11 +++++++ config_test_native_sql.go | 29 +++++++++++++++++ payments/db/sql_store.go | 67 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 payments/db/sql_store.go diff --git a/config_builder.go b/config_builder.go index 3fe62cd2ad1..07196b21c4f 100644 --- a/config_builder.go +++ b/config_builder.go @@ -1228,6 +1228,26 @@ func (d *DefaultDatabaseBuilder) BuildDatabase( return nil, nil, err } + + // Create the payments DB. + // + // NOTE: In the regular build, this will construct a kvdb + // backed payments backend. With the test_native_sql tag, it + // will build a SQL payments backend. + sqlPaymentsDB, err := d.getPaymentsStore( + baseDB, dbs.ChanStateDB.Backend, + paymentsdb.WithKeepFailedPaymentAttempts( + cfg.KeepFailedPaymentAttempts, + ), + ) + if err != nil { + err = fmt.Errorf("unable to get payments store: %w", + err) + + return nil, nil, err + } + + dbs.PaymentsDB = sqlPaymentsDB } else { // Check if the invoice bucket tombstone is set. If it is, we // need to return and ask the user switch back to using the @@ -1256,40 +1276,35 @@ func (d *DefaultDatabaseBuilder) BuildDatabase( if err != nil { return nil, nil, err } - } - dbs.GraphDB, err = graphdb.NewChannelGraph(graphStore, chanGraphOpts...) - if err != nil { - cleanUp() + // Create the payments DB. + kvPaymentsDB, err := paymentsdb.NewKVStore( + dbs.ChanStateDB, + paymentsdb.WithKeepFailedPaymentAttempts( + cfg.KeepFailedPaymentAttempts, + ), + ) + if err != nil { + cleanUp() - err = fmt.Errorf("unable to open channel graph DB: %w", err) - d.logger.Error(err) + err = fmt.Errorf("unable to open payments DB: %w", err) + d.logger.Error(err) - return nil, nil, err - } + return nil, nil, err + } - // Mount the payments DB which is only KV for now. - // - // TODO(ziggie): Add support for SQL payments DB. - // Mount the payments DB for the KV store. - paymentsDBOptions := []paymentsdb.OptionModifier{ - paymentsdb.WithKeepFailedPaymentAttempts( - cfg.KeepFailedPaymentAttempts, - ), + dbs.PaymentsDB = kvPaymentsDB } - kvPaymentsDB, err := paymentsdb.NewKVStore( - dbs.ChanStateDB, - paymentsDBOptions..., - ) + + dbs.GraphDB, err = graphdb.NewChannelGraph(graphStore, chanGraphOpts...) if err != nil { cleanUp() - err = fmt.Errorf("unable to open payments DB: %w", err) + err = fmt.Errorf("unable to open channel graph DB: %w", err) d.logger.Error(err) return nil, nil, err } - dbs.PaymentsDB = kvPaymentsDB // Wrap the watchtower client DB and make sure we clean up. if cfg.WtClient.Active { diff --git a/config_prod.go b/config_prod.go index 60dba8bb50c..02b7d2aac8a 100644 --- a/config_prod.go +++ b/config_prod.go @@ -6,6 +6,8 @@ import ( "context" "github.com/lightningnetwork/lnd/kvdb" + paymentsdb "github.com/lightningnetwork/lnd/payments/db" + "github.com/lightningnetwork/lnd/sqldb" "github.com/lightningnetwork/lnd/sqldb/sqlc" ) @@ -24,3 +26,12 @@ func (d *DefaultDatabaseBuilder) getSQLMigration(ctx context.Context, return nil, false } + +// getPaymentsStore returns a paymentsdb.DB backed by a paymentsdb.KVStore +// implementation. +func (d *DefaultDatabaseBuilder) getPaymentsStore(_ *sqldb.BaseDB, + kvBackend kvdb.Backend, + opts ...paymentsdb.OptionModifier) (paymentsdb.DB, error) { + + return paymentsdb.NewKVStore(kvBackend, opts...) +} diff --git a/config_test_native_sql.go b/config_test_native_sql.go index 91589fa688e..efc6ed81e4a 100644 --- a/config_test_native_sql.go +++ b/config_test_native_sql.go @@ -4,8 +4,12 @@ package lnd import ( "context" + "database/sql" "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lncfg" + paymentsdb "github.com/lightningnetwork/lnd/payments/db" + "github.com/lightningnetwork/lnd/sqldb" "github.com/lightningnetwork/lnd/sqldb/sqlc" ) @@ -25,3 +29,28 @@ func (d *DefaultDatabaseBuilder) getSQLMigration(_ context.Context, return nil, false } } + +// getPaymentsStore returns a paymentsdb.DB backed by a paymentsdb.SQLStore +// implementation. +func (d *DefaultDatabaseBuilder) getPaymentsStore(baseDB *sqldb.BaseDB, + kvBackend kvdb.Backend, + opts ...paymentsdb.OptionModifier) (paymentsdb.DB, error) { + + paymentsExecutor := sqldb.NewTransactionExecutor( + baseDB, func(tx *sql.Tx) paymentsdb.SQLQueries { + return baseDB.WithTx(tx) + }, + ) + + queryConfig := d.cfg.DB.Sqlite.QueryConfig + if d.cfg.DB.Backend == lncfg.PostgresBackend { + queryConfig = d.cfg.DB.Postgres.QueryConfig + } + + return paymentsdb.NewSQLStore( + &paymentsdb.SQLStoreConfig{ + QueryCfg: &queryConfig, + }, + paymentsExecutor, opts..., + ) +} diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go new file mode 100644 index 00000000000..12585caf64e --- /dev/null +++ b/payments/db/sql_store.go @@ -0,0 +1,67 @@ +package paymentsdb + +import ( + "fmt" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// SQLQueries is a subset of the sqlc.Querier interface that can be used to +// execute queries against the SQL payments tables. +type SQLQueries interface { +} + +// BatchedSQLQueries is a version of the SQLQueries that's capable +// of batched database operations. +type BatchedSQLQueries interface { + SQLQueries + sqldb.BatchedTx[SQLQueries] +} + +// SQLStore represents a storage backend. +type SQLStore struct { + // TODO(ziggie): Remove the KVStore once all the interface functions are + // implemented. + KVStore + + cfg *SQLStoreConfig + db BatchedSQLQueries + + // keepFailedPaymentAttempts is a flag that indicates whether we should + // keep failed payment attempts in the database. + keepFailedPaymentAttempts bool +} + +// A compile-time constraint to ensure SQLStore implements DB. +var _ DB = (*SQLStore)(nil) + +// SQLStoreConfig holds the configuration for the SQLStore. +type SQLStoreConfig struct { + // QueryConfig holds configuration values for SQL queries. + QueryCfg *sqldb.QueryConfig +} + +// NewSQLStore creates a new SQLStore instance given an open +// BatchedSQLPaymentsQueries storage backend. +func NewSQLStore(cfg *SQLStoreConfig, db BatchedSQLQueries, + options ...OptionModifier) (*SQLStore, error) { + + opts := DefaultOptions() + for _, applyOption := range options { + applyOption(opts) + } + + if opts.NoMigration { + return nil, fmt.Errorf("the NoMigration option is not yet " + + "supported for SQL stores") + } + + return &SQLStore{ + cfg: cfg, + db: db, + keepFailedPaymentAttempts: opts.KeepFailedPaymentAttempts, + }, nil +} + +// A compile-time constraint to ensure SQLStore implements DB. +var _ DB = (*SQLStore)(nil) From dd585a821b6f2b43f5446e1ae5fe73d6f9adb046 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 29 Sep 2025 17:49:22 +0200 Subject: [PATCH 02/78] sqldb: add payment sql tables This does not include duplicate payments yet. They will be added when the migration code is introduced for payments. --- .../sqlc/migrations/000009_payments.down.sql | 54 +++ sqldb/sqlc/migrations/000009_payments.up.sql | 434 ++++++++++++++++++ sqldb/sqlc/models.go | 90 ++++ 3 files changed, 578 insertions(+) create mode 100644 sqldb/sqlc/migrations/000009_payments.down.sql create mode 100644 sqldb/sqlc/migrations/000009_payments.up.sql diff --git a/sqldb/sqlc/migrations/000009_payments.down.sql b/sqldb/sqlc/migrations/000009_payments.down.sql new file mode 100644 index 00000000000..62b19cb991e --- /dev/null +++ b/sqldb/sqlc/migrations/000009_payments.down.sql @@ -0,0 +1,54 @@ +-- ───────────────────────────────────────────── +-- Drop custom TLV record tables first (they have no dependents). +-- ───────────────────────────────────────────── + +DROP TABLE IF EXISTS payment_hop_custom_records; +DROP TABLE IF EXISTS payment_attempt_first_hop_custom_records; +DROP TABLE IF EXISTS payment_first_hop_custom_records; + +-- ───────────────────────────────────────────── +-- Drop per-hop payload tables before dropping the base hops table. +-- ───────────────────────────────────────────── + +DROP TABLE IF EXISTS payment_route_hop_blinded; +DROP TABLE IF EXISTS payment_route_hop_amp; +DROP TABLE IF EXISTS payment_route_hop_mpp; + +-- ───────────────────────────────────────────── +-- Drop route hops table and its indexes. +-- ───────────────────────────────────────────── + +DROP INDEX IF EXISTS idx_route_hops_htlc_attempt_index; +DROP TABLE IF EXISTS payment_route_hops; + +-- ───────────────────────────────────────────── +-- Drop HTLC attempt resolution table and its indexes. +-- ───────────────────────────────────────────── + +DROP INDEX IF EXISTS idx_htlc_resolutions_type; +DROP INDEX IF EXISTS idx_htlc_resolutions_time; +DROP TABLE IF EXISTS payment_htlc_attempt_resolutions; + +-- ───────────────────────────────────────────── +-- Drop HTLC attempts table and its indexes. +-- ───────────────────────────────────────────── + +DROP INDEX IF EXISTS idx_htlc_payment_id; +DROP INDEX IF EXISTS idx_htlc_attempt_index; +DROP INDEX IF EXISTS idx_htlc_payment_hash; +DROP INDEX IF EXISTS idx_htlc_attempt_time; +DROP TABLE IF EXISTS payment_htlc_attempts; + +-- ───────────────────────────────────────────── +-- Drop payments table and its indexes. +-- ───────────────────────────────────────────── + +DROP INDEX IF EXISTS idx_payments_created_at; +DROP TABLE IF EXISTS payments; + +-- ───────────────────────────────────────────── +-- Drop payment intents table and its indexes. +-- ───────────────────────────────────────────── + +DROP INDEX IF EXISTS idx_payment_intents_type; +DROP TABLE IF EXISTS payment_intents; \ No newline at end of file diff --git a/sqldb/sqlc/migrations/000009_payments.up.sql b/sqldb/sqlc/migrations/000009_payments.up.sql new file mode 100644 index 00000000000..c856db8f442 --- /dev/null +++ b/sqldb/sqlc/migrations/000009_payments.up.sql @@ -0,0 +1,434 @@ +-- ───────────────────────────────────────────── +-- Payment System Schema Migration +-- ───────────────────────────────────────────── +-- This migration creates the complete payment system schema including: +-- - Payment intents (BOLT 11/12 invoices, offers) +-- - Payment attempts and HTLC tracking +-- - Route hops and custom TLV records +-- - Resolution tracking for settled/failed payments +-- ───────────────────────────────────────────── + +-- ───────────────────────────────────────────── +-- Payment Intents Table +-- ───────────────────────────────────────────── +-- Stores the descriptor of what the payment is paying for. +-- Depending on the type, the payload might contain: +-- - BOLT 11 invoice data +-- - BOLT 12 offer data +-- - NULL for legacy hash-only/keysend style payments +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_intents ( + -- Primary key for the intent record + id INTEGER PRIMARY KEY, + + -- The type of intent (e.g. 0 = bolt11_invoice, 1 = bolt12_offer) + -- Uses SMALLINT (int16) for efficient storage of enum values + intent_type SMALLINT NOT NULL, + + -- The serialized payload for the payment intent + -- Content depends on type - could be invoice, offer, or NULL + intent_payload BLOB +); + +-- Index for efficient querying by intent type +CREATE INDEX IF NOT EXISTS idx_payment_intents_type +ON payment_intents(intent_type); + +-- ───────────────────────────────────────────── +-- Payments Table +-- ───────────────────────────────────────────── +-- Stores all payments including all known payment types: +-- - Legacy payments +-- - Multi-Path Payments (MPP) +-- - Atomic Multi-Path Payments (AMP) +-- - Blinded payments +-- - Keysend payments +-- - Spontaneous AMP payments +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payments ( + -- Primary key for the payment record + id INTEGER PRIMARY KEY, + + -- Optional reference to the payment intent this payment was derived from + -- Links to BOLT 11 invoice, BOLT 12 offer, etc. + intent_id BIGINT REFERENCES payment_intents (id), + + -- The amount of the payment in millisatoshis + amount_msat BIGINT NOT NULL, + + -- Timestamp when the payment was created + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Logical identifier for the payment + -- For legacy + MPP: matches the HTLC hash + -- For AMP: the setID + -- For future intent types: any unique payment-level key + payment_identifier BLOB NOT NULL, + + -- The reason for payment failure (only set if payment has failed) + -- Integer enum type indicating failure reason + fail_reason INTEGER, + + -- Ensure payment identifiers are unique across all payments + CONSTRAINT idx_payments_payment_identifier_unique + UNIQUE (payment_identifier) +); + +-- Index for efficient querying by creation time (for chronological ordering) +CREATE INDEX IF NOT EXISTS idx_payments_created_at +ON payments(created_at); + +-- ───────────────────────────────────────────── +-- Payment HTLC Attempts Table +-- ───────────────────────────────────────────── +-- Stores all HTLC attempts for a payment. A payment can have multiple +-- HTLC attempts depending on whether the payment is split and also +-- if some attempts fail and need to be retried. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_htlc_attempts ( + -- Primary key for the HTLC attempt record + id INTEGER PRIMARY KEY, + + -- The index of the HTLC attempt + -- TODO: This will be removed and the primary key will be used only + attempt_index BIGINT NOT NULL, + + -- Reference to the parent payment + payment_id BIGINT NOT NULL REFERENCES payments (id) ON DELETE CASCADE, + + -- The session key of the HTLC attempt (also known as ephemeral key + -- of the Sphinx packet used for onion routing) + session_key BLOB NOT NULL, + + -- Timestamp when the HTLC attempt was created + attempt_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- The payment hash for the payment attempt + -- The hash the HTLC will be locked to - this does not need to be + -- equal to the payment level identifier (e.g., for AMP payments) + payment_hash BLOB NOT NULL, + + -- First hop amount in millisatoshis of the HTLC attempt + -- Normally the same as the total amount of the route, but when using + -- custom channels this might be different + first_hop_amount_msat BIGINT NOT NULL, + + -- ───────────────────────────────────────────── + -- Route Information for the HTLC Attempt + -- ───────────────────────────────────────────── + -- Every attempt has one route, so there is a 1:1 relationship between + -- attempts and routes. The route itself can be found in the hops table. + -- ───────────────────────────────────────────── + + -- The total time lock of the route (in blocks) + route_total_time_lock INTEGER NOT NULL, + + -- The total amount of the route in millisatoshis + route_total_amount BIGINT NOT NULL, + + -- The source key of the route (our node's public key) + route_source_key BLOB NOT NULL, + + -- Ensure attempt indices are unique across all attempts + CONSTRAINT idx_htlc_attempt_index_unique + UNIQUE (attempt_index), + + -- Ensure session keys are unique (each attempt has unique session key) + CONSTRAINT idx_htlc_session_key_unique + UNIQUE (session_key) +); + +-- Index for efficient querying by payment ID (find all attempts for a payment) +CREATE INDEX IF NOT EXISTS idx_htlc_payment_id +ON payment_htlc_attempts(payment_id); + +-- Index for efficient querying by attempt index (for lookups and joins) +CREATE INDEX IF NOT EXISTS idx_htlc_attempt_index +ON payment_htlc_attempts(attempt_index); + +-- Index for efficient querying by payment hash (for HTLC matching) +CREATE INDEX IF NOT EXISTS idx_htlc_payment_hash +ON payment_htlc_attempts(payment_hash); + +-- Index for efficient querying by attempt time (for chronological ordering) +CREATE INDEX IF NOT EXISTS idx_htlc_attempt_time +ON payment_htlc_attempts(attempt_time); + +-- ───────────────────────────────────────────── +-- HTLC Attempt Resolutions Table +-- ───────────────────────────────────────────── +-- Stores resolution metadata for HTLC attempts. Rows appear once an +-- attempt settles or fails, providing the final outcome and timing. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_htlc_attempt_resolutions ( + -- Primary key referencing the HTLC attempt + -- TODO: This will be removed and the primary key will be used only + attempt_index INTEGER PRIMARY KEY + REFERENCES payment_htlc_attempts (attempt_index) ON DELETE CASCADE, + + -- Timestamp when the attempt was resolved (settled or failed) + resolution_time TIMESTAMP NOT NULL, + + -- Outcome of the attempt: 1 = settled, 2 = failed + resolution_type INTEGER NOT NULL CHECK (resolution_type IN (1, 2)), + + -- Settlement payload (only populated for settled attempts) + -- Contains the preimage that proves payment completion + settle_preimage BLOB, + + -- Failure payload (only populated for failed attempts) + -- Index of the node that sent the failure + failure_source_index INTEGER, + + -- HTLC failure reason code + htlc_fail_reason INTEGER, + + -- Failure message from the failing node + failure_msg BLOB, + + -- Ensure data integrity: settled attempts must have preimage, + -- failed attempts must not have preimage + CHECK ( + (resolution_type = 1 AND settle_preimage IS NOT NULL AND + failure_source_index IS NULL AND htlc_fail_reason IS NULL AND + failure_msg IS NULL) + OR + (resolution_type = 2 AND settle_preimage IS NULL) + ) +); + +-- Index for efficient querying by resolution type (settled vs failed) +CREATE INDEX IF NOT EXISTS idx_htlc_resolutions_type +ON payment_htlc_attempt_resolutions(resolution_type); + +-- Index for efficient querying by resolution time (for chronological analysis) +CREATE INDEX IF NOT EXISTS idx_htlc_resolutions_time +ON payment_htlc_attempt_resolutions(resolution_time); + +-- ───────────────────────────────────────────── +-- Payment Route Hops Table +-- ───────────────────────────────────────────── +-- Stores the individual hops of a payment route. An attempt has only +-- one route, but a route can consist of several hops through the network. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_route_hops ( + -- Primary key for the hop record + id INTEGER PRIMARY KEY, + + -- Reference to the HTLC attempt this hop belongs to + htlc_attempt_index BIGINT NOT NULL + REFERENCES payment_htlc_attempts (attempt_index) ON DELETE CASCADE, + + -- The order/index of this hop within the route (0-based) + hop_index INTEGER NOT NULL, + + -- The public key of the hop (node's public key) + pub_key BLOB, + + -- The short channel ID of the hop (channel identifier) + scid TEXT NOT NULL, + + -- The outgoing time lock of the hop (in blocks) + outgoing_time_lock INTEGER NOT NULL, + + -- The amount to forward to the next hop (in millisatoshis) + amt_to_forward BIGINT NOT NULL, + + -- The metadata blob transmitted to the hop (onion payload) + meta_data BLOB, + + -- Ensure each attempt can only have one hop at each hop index + -- This prevents duplicate hops in the same position + CONSTRAINT idx_route_hops_unique_hop_per_attempt + UNIQUE (htlc_attempt_index, hop_index) +); + +-- Index for efficient querying by attempt index (find all hops for an attempt) +CREATE INDEX IF NOT EXISTS idx_route_hops_htlc_attempt_index +ON payment_route_hops(htlc_attempt_index); + +-- ───────────────────────────────────────────── +-- Per-Hop Payload Tables +-- ───────────────────────────────────────────── +-- These tables store specialized payload data for different payment types. +-- Each table is only populated for hops that require that specific payload. +-- ───────────────────────────────────────────── + +-- ───────────────────────────────────────────── +-- MPP (Multi-Path Payment) Payload Table +-- ───────────────────────────────────────────── +-- Stores MPP-specific payload data. Only present for the final hop +-- of an MPP attempt, containing payment address and total amount info. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_route_hop_mpp ( + -- Primary key referencing the hop + hop_id INTEGER PRIMARY KEY + REFERENCES payment_route_hops (id) ON DELETE CASCADE, + + -- The payment address of the MPP path (for payment correlation) + payment_addr BLOB NOT NULL, + + -- The total amount of the MPP payment in millisatoshis + -- This is the sum of all parts in the multi-path payment + total_msat BIGINT NOT NULL +); + +-- ───────────────────────────────────────────── +-- AMP (Atomic Multi-Path Payment) Payload Table +-- ───────────────────────────────────────────── +-- Stores AMP-specific payload data. Only present for the final hop +-- of an AMP attempt, containing share information for atomicity. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_route_hop_amp ( + -- Primary key referencing the hop + hop_id INTEGER PRIMARY KEY + REFERENCES payment_route_hops (id) ON DELETE CASCADE, + + -- The root share of the AMP path (for share reconstruction) + root_share BLOB NOT NULL, + + -- The set ID of the AMP path (groups related AMP parts) + set_id BLOB NOT NULL, + + -- The child index of the AMP path (identifies this part) + child_index INTEGER NOT NULL +); + +-- ───────────────────────────────────────────── +-- Blinded Route Payload Table +-- ───────────────────────────────────────────── +-- Stores blinded route payload data. Rows only exist for hops that +-- are part of a blinded path, providing privacy-preserving routing. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_route_hop_blinded ( + -- Primary key referencing the hop + hop_id INTEGER PRIMARY KEY + REFERENCES payment_route_hops (id) ON DELETE CASCADE, + + -- The encrypted payload for the blinded hop + encrypted_data BLOB NOT NULL, + + -- Only set for the introduction point of the blinded path + -- Contains the blinding point for the introduction node + blinding_point BLOB, + + -- Only set for the final hop in the blinded path + -- Contains the total amount for the entire blinded path + blinded_path_total_amt BIGINT +); + +-- ───────────────────────────────────────────── +-- Custom TLV Records Tables +-- ───────────────────────────────────────────── +-- These tables store custom TLV (Type-Length-Value) records associated +-- with payments, attempts, and hops. This is a denormalized structure +-- designed to simplify cascade deletions, as each record is owned by +-- a single parent entity. +-- ───────────────────────────────────────────── + +-- ───────────────────────────────────────────── +-- Payment-Level First Hop Custom Records +-- ───────────────────────────────────────────── +-- Stores custom TLV records that are part of the first hop of a payment. +-- These records are sent to the first hop and are payment-level data. +-- +-- NOTE: This relates to the custom tlv record data which is sent to the first +-- hop in the wire message (UpdateAddHTLC) NOT the onion packet. +-- +-- TODO(ziggie): We store mostly redundant data here and on the attempt level. +-- This might be improved in the future to reduce duplication. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_first_hop_custom_records ( + -- Primary key for the custom record + id INTEGER PRIMARY KEY, + + -- Reference to the parent payment + payment_id BIGINT NOT NULL REFERENCES payments (id) ON DELETE CASCADE, + + -- The TLV type identifier (must be >= 65536 for custom records) + key BIGINT NOT NULL, + + -- The TLV value data + value BLOB NOT NULL, + + -- Ensure we only store custom TLV records (not standard ones) + CHECK (key >= 65536), + + -- Ensure each payment can only have one record per TLV type + CONSTRAINT idx_payment_first_hop_custom_records_unique + UNIQUE (payment_id, key) +); + +-- ───────────────────────────────────────────── +-- Attempt-Level First Hop Custom Records +-- ───────────────────────────────────────────── +-- Stores custom TLV records for the first hop on the route level. +-- These might be different from the payment-level first hop records +-- in case of custom channels or route-specific modifications. +-- +-- NOTE: This relates to the custom tlv record data which is sent to the first +-- hop in the wire message (UpdateAddHTLC) NOT the onion packet. +-- +-- TODO(ziggie): We store mostly redundant data here and on the payment level. +-- This might be improved in the future to reduce duplication. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_attempt_first_hop_custom_records ( + -- Primary key for the custom record + id INTEGER PRIMARY KEY, + + -- Reference to the parent HTLC attempt + htlc_attempt_index BIGINT NOT NULL + REFERENCES payment_htlc_attempts (attempt_index) ON DELETE CASCADE, + + -- The TLV type identifier (must be >= 65536 for custom records) + key BIGINT NOT NULL, + + -- The TLV value data + value BLOB NOT NULL, + + -- Ensure we only store custom TLV records (not standard ones) + CHECK (key >= 65536), + + -- Ensure each attempt can only have one record per TLV type + CONSTRAINT idx_payment_attempt_first_hop_custom_records_unique + UNIQUE (htlc_attempt_index, key) +); + +-- ───────────────────────────────────────────── +-- Hop-Level Custom Records +-- ───────────────────────────────────────────── +-- Stores custom TLV records associated with a specific hop within +-- a payment route. These records are sent to that specific hop +-- and are hop-specific data. +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_hop_custom_records ( + -- Primary key for the custom record + id INTEGER PRIMARY KEY, + + -- Reference to the parent hop + hop_id BIGINT NOT NULL REFERENCES payment_route_hops (id) ON DELETE CASCADE, + + -- The TLV type identifier (must be >= 65536 for custom records) + key BIGINT NOT NULL, + + -- The TLV value data + value BLOB NOT NULL, + + -- Ensure we only store custom TLV records (not standard ones) + CHECK (key >= 65536), + + -- Ensure each hop can only have one record per TLV type + CONSTRAINT idx_payment_hop_custom_records_unique + UNIQUE (hop_id, key) +); \ No newline at end of file diff --git a/sqldb/sqlc/models.go b/sqldb/sqlc/models.go index 359720096c5..899c572e549 100644 --- a/sqldb/sqlc/models.go +++ b/sqldb/sqlc/models.go @@ -208,3 +208,93 @@ type MigrationTracker struct { Version int32 MigrationTime time.Time } + +type Payment struct { + ID int64 + IntentID sql.NullInt64 + AmountMsat int64 + CreatedAt time.Time + PaymentIdentifier []byte + FailReason sql.NullInt32 +} + +type PaymentAttemptFirstHopCustomRecord struct { + ID int64 + HtlcAttemptIndex int64 + Key int64 + Value []byte +} + +type PaymentFirstHopCustomRecord struct { + ID int64 + PaymentID int64 + Key int64 + Value []byte +} + +type PaymentHopCustomRecord struct { + ID int64 + HopID int64 + Key int64 + Value []byte +} + +type PaymentHtlcAttempt struct { + ID int64 + AttemptIndex int64 + PaymentID int64 + SessionKey []byte + AttemptTime time.Time + PaymentHash []byte + FirstHopAmountMsat int64 + RouteTotalTimeLock int32 + RouteTotalAmount int64 + RouteSourceKey []byte +} + +type PaymentHtlcAttemptResolution struct { + AttemptIndex int64 + ResolutionTime time.Time + ResolutionType int32 + SettlePreimage []byte + FailureSourceIndex sql.NullInt32 + HtlcFailReason sql.NullInt32 + FailureMsg []byte +} + +type PaymentIntent struct { + ID int64 + IntentType int16 + IntentPayload []byte +} + +type PaymentRouteHop struct { + ID int64 + HtlcAttemptIndex int64 + HopIndex int32 + PubKey []byte + Scid string + OutgoingTimeLock int32 + AmtToForward int64 + MetaData []byte +} + +type PaymentRouteHopAmp struct { + HopID int64 + RootShare []byte + SetID []byte + ChildIndex int32 +} + +type PaymentRouteHopBlinded struct { + HopID int64 + EncryptedData []byte + BlindingPoint []byte + BlindedPathTotalAmt sql.NullInt64 +} + +type PaymentRouteHopMpp struct { + HopID int64 + PaymentAddr []byte + TotalMsat int64 +} From 8c7b2367e5ba69dc2178421255486bf31df62836 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 29 Sep 2025 18:42:47 +0200 Subject: [PATCH 03/78] lnd: make the payment schema migration available for testing We allow the migration of the payment schema to be applied when the test_native_sql is active to make sure the tables are properly contructed. --- sqldb/migrations_dev.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sqldb/migrations_dev.go b/sqldb/migrations_dev.go index a1b25019aee..4158cb94903 100644 --- a/sqldb/migrations_dev.go +++ b/sqldb/migrations_dev.go @@ -2,4 +2,10 @@ package sqldb -var migrationAdditions []MigrationConfig +var migrationAdditions = []MigrationConfig{ + { + Name: "000009_payments", + Version: 11, + SchemaVersion: 9, + }, +} From b6a05f795116d68f6aadb501e2d817b289591aef Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 12 Oct 2025 12:55:57 +0200 Subject: [PATCH 04/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index 7a180a7dd14..b7d969acd31 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -197,6 +197,13 @@ [4](https://github.com/lightningnetwork/lnd/pull/10542), [5](https://github.com/lightningnetwork/lnd/pull/10572). +* Payment Store SQL implementation and migration project: + * Introduce an [abstract payment + store](https://github.com/lightningnetwork/lnd/pull/10153) interface and + refacotor the payment related LND code to make it more modular. + * Implement the SQL backend for the [payments + database](https://github.com/lightningnetwork/lnd/pull/9147) + ## Code Health ## Tooling and Documentation @@ -207,8 +214,10 @@ * Boris Nagaev * Elle Mouton * Erick Cestari +* Gijs van Dam * hieblmi * Matt Morehouse * Mohamed Awnallah * Nishant Bansal * Pins +* Ziggie From e7a30966218c4543baec5943da185587b0419213 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 15:00:40 +0200 Subject: [PATCH 05/78] sqldb: add index and comment to payment tables --- sqldb/sqlc/migrations/000009_payments.up.sql | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sqldb/sqlc/migrations/000009_payments.up.sql b/sqldb/sqlc/migrations/000009_payments.up.sql index c856db8f442..0d85b497b00 100644 --- a/sqldb/sqlc/migrations/000009_payments.up.sql +++ b/sqldb/sqlc/migrations/000009_payments.up.sql @@ -32,9 +32,13 @@ CREATE TABLE IF NOT EXISTS payment_intents ( ); -- Index for efficient querying by intent type -CREATE INDEX IF NOT EXISTS idx_payment_intents_type +CREATE INDEX IF NOT EXISTS idx_payment_intents_type ON payment_intents(intent_type); +-- Unique constraint for deduplication of payment intents +CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_intents_unique +ON payment_intents(intent_type, intent_payload); + -- ───────────────────────────────────────────── -- Payments Table -- ───────────────────────────────────────────── @@ -187,7 +191,8 @@ CREATE TABLE IF NOT EXISTS payment_htlc_attempt_resolutions ( -- HTLC failure reason code htlc_fail_reason INTEGER, - -- Failure message from the failing node + -- Failure message from the failing node, this message is binary encoded + -- using the lightning wire protocol, see also lnwire/onion_error.go failure_msg BLOB, -- Ensure data integrity: settled attempts must have preimage, From 18e776883725eea56ae507aa4267ca8387223607 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 12 Oct 2025 15:18:05 +0200 Subject: [PATCH 06/78] multi: add relevant queries for QueryPayments implemenation --- payments/db/sql_store.go | 18 + sqldb/sqlc/payments.sql.go | 594 ++++++++++++++++++++++++++++++++ sqldb/sqlc/querier.go | 11 + sqldb/sqlc/queries/payments.sql | 153 ++++++++ 4 files changed, 776 insertions(+) create mode 100644 sqldb/sqlc/payments.sql.go create mode 100644 sqldb/sqlc/queries/payments.sql diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 12585caf64e..ced061afafa 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1,14 +1,32 @@ package paymentsdb import ( + "context" "fmt" "github.com/lightningnetwork/lnd/sqldb" + "github.com/lightningnetwork/lnd/sqldb/sqlc" ) // SQLQueries is a subset of the sqlc.Querier interface that can be used to // execute queries against the SQL payments tables. type SQLQueries interface { + /* + Payment DB read operations. + */ + FilterPayments(ctx context.Context, query sqlc.FilterPaymentsParams) ([]sqlc.FilterPaymentsRow, error) + FetchPayment(ctx context.Context, paymentIdentifier []byte) (sqlc.FetchPaymentRow, error) + FetchPaymentsByIDs(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchPaymentsByIDsRow, error) + + CountPayments(ctx context.Context) (int64, error) + + FetchHtlcAttemptsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptsForPaymentsRow, error) + FetchAllInflightAttempts(ctx context.Context) ([]sqlc.PaymentHtlcAttempt, error) + FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.FetchHopsForAttemptsRow, error) + + FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIDs []int64) ([]sqlc.PaymentFirstHopCustomRecord, error) + FetchRouteLevelFirstHopCustomRecords(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.PaymentAttemptFirstHopCustomRecord, error) + FetchHopLevelCustomRecords(ctx context.Context, hopIDs []int64) ([]sqlc.PaymentHopCustomRecord, error) } // BatchedSQLQueries is a version of the SQLQueries that's capable diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go new file mode 100644 index 00000000000..83c1c7f1f04 --- /dev/null +++ b/sqldb/sqlc/payments.sql.go @@ -0,0 +1,594 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: payments.sql + +package sqlc + +import ( + "context" + "database/sql" + "strings" + "time" +) + +const countPayments = `-- name: CountPayments :one +SELECT COUNT(*) FROM payments +` + +func (q *Queries) CountPayments(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countPayments) + var count int64 + err := row.Scan(&count) + return count, err +} + +const fetchAllInflightAttempts = `-- name: FetchAllInflightAttempts :many +SELECT + ha.id, + ha.attempt_index, + ha.payment_id, + ha.session_key, + ha.attempt_time, + ha.payment_hash, + ha.first_hop_amount_msat, + ha.route_total_time_lock, + ha.route_total_amount, + ha.route_source_key +FROM payment_htlc_attempts ha +WHERE NOT EXISTS ( + SELECT 1 FROM payment_htlc_attempt_resolutions hr + WHERE hr.attempt_index = ha.attempt_index +) +ORDER BY ha.attempt_index ASC +` + +// Fetch all inflight attempts across all payments +func (q *Queries) FetchAllInflightAttempts(ctx context.Context) ([]PaymentHtlcAttempt, error) { + rows, err := q.db.QueryContext(ctx, fetchAllInflightAttempts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaymentHtlcAttempt + for rows.Next() { + var i PaymentHtlcAttempt + if err := rows.Scan( + &i.ID, + &i.AttemptIndex, + &i.PaymentID, + &i.SessionKey, + &i.AttemptTime, + &i.PaymentHash, + &i.FirstHopAmountMsat, + &i.RouteTotalTimeLock, + &i.RouteTotalAmount, + &i.RouteSourceKey, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const fetchHopLevelCustomRecords = `-- name: FetchHopLevelCustomRecords :many +SELECT + l.id, + l.hop_id, + l.key, + l.value +FROM payment_hop_custom_records l +WHERE l.hop_id IN (/*SLICE:hop_ids*/?) +ORDER BY l.hop_id ASC, l.key ASC +` + +func (q *Queries) FetchHopLevelCustomRecords(ctx context.Context, hopIds []int64) ([]PaymentHopCustomRecord, error) { + query := fetchHopLevelCustomRecords + var queryParams []interface{} + if len(hopIds) > 0 { + for _, v := range hopIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:hop_ids*/?", makeQueryParams(len(queryParams), len(hopIds)), 1) + } else { + query = strings.Replace(query, "/*SLICE:hop_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaymentHopCustomRecord + for rows.Next() { + var i PaymentHopCustomRecord + if err := rows.Scan( + &i.ID, + &i.HopID, + &i.Key, + &i.Value, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const fetchHopsForAttempts = `-- name: FetchHopsForAttempts :many +SELECT + h.id, + h.htlc_attempt_index, + h.hop_index, + h.pub_key, + h.scid, + h.outgoing_time_lock, + h.amt_to_forward, + h.meta_data, + m.payment_addr AS mpp_payment_addr, + m.total_msat AS mpp_total_msat, + a.root_share AS amp_root_share, + a.set_id AS amp_set_id, + a.child_index AS amp_child_index, + b.encrypted_data, + b.blinding_point, + b.blinded_path_total_amt +FROM payment_route_hops h +LEFT JOIN payment_route_hop_mpp m ON m.hop_id = h.id +LEFT JOIN payment_route_hop_amp a ON a.hop_id = h.id +LEFT JOIN payment_route_hop_blinded b ON b.hop_id = h.id +WHERE h.htlc_attempt_index IN (/*SLICE:htlc_attempt_indices*/?) +ORDER BY h.htlc_attempt_index ASC, h.hop_index ASC +` + +type FetchHopsForAttemptsRow struct { + ID int64 + HtlcAttemptIndex int64 + HopIndex int32 + PubKey []byte + Scid string + OutgoingTimeLock int32 + AmtToForward int64 + MetaData []byte + MppPaymentAddr []byte + MppTotalMsat sql.NullInt64 + AmpRootShare []byte + AmpSetID []byte + AmpChildIndex sql.NullInt32 + EncryptedData []byte + BlindingPoint []byte + BlindedPathTotalAmt sql.NullInt64 +} + +func (q *Queries) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]FetchHopsForAttemptsRow, error) { + query := fetchHopsForAttempts + var queryParams []interface{} + if len(htlcAttemptIndices) > 0 { + for _, v := range htlcAttemptIndices { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:htlc_attempt_indices*/?", makeQueryParams(len(queryParams), len(htlcAttemptIndices)), 1) + } else { + query = strings.Replace(query, "/*SLICE:htlc_attempt_indices*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FetchHopsForAttemptsRow + for rows.Next() { + var i FetchHopsForAttemptsRow + if err := rows.Scan( + &i.ID, + &i.HtlcAttemptIndex, + &i.HopIndex, + &i.PubKey, + &i.Scid, + &i.OutgoingTimeLock, + &i.AmtToForward, + &i.MetaData, + &i.MppPaymentAddr, + &i.MppTotalMsat, + &i.AmpRootShare, + &i.AmpSetID, + &i.AmpChildIndex, + &i.EncryptedData, + &i.BlindingPoint, + &i.BlindedPathTotalAmt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const fetchHtlcAttemptsForPayments = `-- name: FetchHtlcAttemptsForPayments :many +SELECT + ha.id, + ha.attempt_index, + ha.payment_id, + ha.session_key, + ha.attempt_time, + ha.payment_hash, + ha.first_hop_amount_msat, + ha.route_total_time_lock, + ha.route_total_amount, + ha.route_source_key, + hr.resolution_type, + hr.resolution_time, + hr.failure_source_index, + hr.htlc_fail_reason, + hr.failure_msg, + hr.settle_preimage +FROM payment_htlc_attempts ha +LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index +WHERE ha.payment_id IN (/*SLICE:payment_ids*/?) +ORDER BY ha.payment_id ASC, ha.attempt_time ASC +` + +type FetchHtlcAttemptsForPaymentsRow struct { + ID int64 + AttemptIndex int64 + PaymentID int64 + SessionKey []byte + AttemptTime time.Time + PaymentHash []byte + FirstHopAmountMsat int64 + RouteTotalTimeLock int32 + RouteTotalAmount int64 + RouteSourceKey []byte + ResolutionType sql.NullInt32 + ResolutionTime sql.NullTime + FailureSourceIndex sql.NullInt32 + HtlcFailReason sql.NullInt32 + FailureMsg []byte + SettlePreimage []byte +} + +func (q *Queries) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptsForPaymentsRow, error) { + query := fetchHtlcAttemptsForPayments + var queryParams []interface{} + if len(paymentIds) > 0 { + for _, v := range paymentIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:payment_ids*/?", makeQueryParams(len(queryParams), len(paymentIds)), 1) + } else { + query = strings.Replace(query, "/*SLICE:payment_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FetchHtlcAttemptsForPaymentsRow + for rows.Next() { + var i FetchHtlcAttemptsForPaymentsRow + if err := rows.Scan( + &i.ID, + &i.AttemptIndex, + &i.PaymentID, + &i.SessionKey, + &i.AttemptTime, + &i.PaymentHash, + &i.FirstHopAmountMsat, + &i.RouteTotalTimeLock, + &i.RouteTotalAmount, + &i.RouteSourceKey, + &i.ResolutionType, + &i.ResolutionTime, + &i.FailureSourceIndex, + &i.HtlcFailReason, + &i.FailureMsg, + &i.SettlePreimage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const fetchPayment = `-- name: FetchPayment :one +SELECT + p.id, p.intent_id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, + i.intent_type AS "intent_type", + i.intent_payload AS "intent_payload" +FROM payments p +LEFT JOIN payment_intents i ON i.id = p.intent_id +WHERE p.payment_identifier = $1 +` + +type FetchPaymentRow struct { + Payment Payment + IntentType sql.NullInt16 + IntentPayload []byte +} + +func (q *Queries) FetchPayment(ctx context.Context, paymentIdentifier []byte) (FetchPaymentRow, error) { + row := q.db.QueryRowContext(ctx, fetchPayment, paymentIdentifier) + var i FetchPaymentRow + err := row.Scan( + &i.Payment.ID, + &i.Payment.IntentID, + &i.Payment.AmountMsat, + &i.Payment.CreatedAt, + &i.Payment.PaymentIdentifier, + &i.Payment.FailReason, + &i.IntentType, + &i.IntentPayload, + ) + return i, err +} + +const fetchPaymentLevelFirstHopCustomRecords = `-- name: FetchPaymentLevelFirstHopCustomRecords :many +SELECT + l.id, + l.payment_id, + l.key, + l.value +FROM payment_first_hop_custom_records l +WHERE l.payment_id IN (/*SLICE:payment_ids*/?) +ORDER BY l.payment_id ASC, l.key ASC +` + +func (q *Queries) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIds []int64) ([]PaymentFirstHopCustomRecord, error) { + query := fetchPaymentLevelFirstHopCustomRecords + var queryParams []interface{} + if len(paymentIds) > 0 { + for _, v := range paymentIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:payment_ids*/?", makeQueryParams(len(queryParams), len(paymentIds)), 1) + } else { + query = strings.Replace(query, "/*SLICE:payment_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaymentFirstHopCustomRecord + for rows.Next() { + var i PaymentFirstHopCustomRecord + if err := rows.Scan( + &i.ID, + &i.PaymentID, + &i.Key, + &i.Value, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const fetchPaymentsByIDs = `-- name: FetchPaymentsByIDs :many +SELECT + p.id, p.intent_id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, + i.intent_type AS "intent_type", + i.intent_payload AS "intent_payload" +FROM payments p +LEFT JOIN payment_intents i ON i.id = p.intent_id +WHERE p.id IN (/*SLICE:payment_ids*/?) +` + +type FetchPaymentsByIDsRow struct { + Payment Payment + IntentType sql.NullInt16 + IntentPayload []byte +} + +func (q *Queries) FetchPaymentsByIDs(ctx context.Context, paymentIds []int64) ([]FetchPaymentsByIDsRow, error) { + query := fetchPaymentsByIDs + var queryParams []interface{} + if len(paymentIds) > 0 { + for _, v := range paymentIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:payment_ids*/?", makeQueryParams(len(queryParams), len(paymentIds)), 1) + } else { + query = strings.Replace(query, "/*SLICE:payment_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FetchPaymentsByIDsRow + for rows.Next() { + var i FetchPaymentsByIDsRow + if err := rows.Scan( + &i.Payment.ID, + &i.Payment.IntentID, + &i.Payment.AmountMsat, + &i.Payment.CreatedAt, + &i.Payment.PaymentIdentifier, + &i.Payment.FailReason, + &i.IntentType, + &i.IntentPayload, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const fetchRouteLevelFirstHopCustomRecords = `-- name: FetchRouteLevelFirstHopCustomRecords :many +SELECT + l.id, + l.htlc_attempt_index, + l.key, + l.value +FROM payment_attempt_first_hop_custom_records l +WHERE l.htlc_attempt_index IN (/*SLICE:htlc_attempt_indices*/?) +ORDER BY l.htlc_attempt_index ASC, l.key ASC +` + +func (q *Queries) FetchRouteLevelFirstHopCustomRecords(ctx context.Context, htlcAttemptIndices []int64) ([]PaymentAttemptFirstHopCustomRecord, error) { + query := fetchRouteLevelFirstHopCustomRecords + var queryParams []interface{} + if len(htlcAttemptIndices) > 0 { + for _, v := range htlcAttemptIndices { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:htlc_attempt_indices*/?", makeQueryParams(len(queryParams), len(htlcAttemptIndices)), 1) + } else { + query = strings.Replace(query, "/*SLICE:htlc_attempt_indices*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaymentAttemptFirstHopCustomRecord + for rows.Next() { + var i PaymentAttemptFirstHopCustomRecord + if err := rows.Scan( + &i.ID, + &i.HtlcAttemptIndex, + &i.Key, + &i.Value, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const filterPayments = `-- name: FilterPayments :many +/* ───────────────────────────────────────────── + fetch queries + ───────────────────────────────────────────── +*/ + +SELECT + p.id, p.intent_id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, + i.intent_type AS "intent_type", + i.intent_payload AS "intent_payload" +FROM payments p +LEFT JOIN payment_intents i ON i.id = p.intent_id +WHERE ( + p.id > $1 OR + $1 IS NULL +) AND ( + p.id < $2 OR + $2 IS NULL +) AND ( + p.created_at >= $3 OR + $3 IS NULL +) AND ( + p.created_at <= $4 OR + $4 IS NULL +) AND ( + i.intent_type = $5 OR + $5 IS NULL OR i.intent_type IS NULL +) +ORDER BY + CASE WHEN $6 = false OR $6 IS NULL THEN p.id END ASC, + CASE WHEN $6 = true THEN p.id END DESC +LIMIT $7 +` + +type FilterPaymentsParams struct { + IndexOffsetGet sql.NullInt64 + IndexOffsetLet sql.NullInt64 + CreatedAfter sql.NullTime + CreatedBefore sql.NullTime + IntentType sql.NullInt16 + Reverse interface{} + NumLimit int32 +} + +type FilterPaymentsRow struct { + Payment Payment + IntentType sql.NullInt16 + IntentPayload []byte +} + +func (q *Queries) FilterPayments(ctx context.Context, arg FilterPaymentsParams) ([]FilterPaymentsRow, error) { + rows, err := q.db.QueryContext(ctx, filterPayments, + arg.IndexOffsetGet, + arg.IndexOffsetLet, + arg.CreatedAfter, + arg.CreatedBefore, + arg.IntentType, + arg.Reverse, + arg.NumLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FilterPaymentsRow + for rows.Next() { + var i FilterPaymentsRow + if err := rows.Scan( + &i.Payment.ID, + &i.Payment.IntentID, + &i.Payment.AmountMsat, + &i.Payment.CreatedAt, + &i.Payment.PaymentIdentifier, + &i.Payment.FailReason, + &i.IntentType, + &i.IntentPayload, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 5f2fc65a9b7..f4c7673076c 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -15,6 +15,7 @@ type Querier interface { AddV1ChannelProof(ctx context.Context, arg AddV1ChannelProofParams) (sql.Result, error) AddV2ChannelProof(ctx context.Context, arg AddV2ChannelProofParams) (sql.Result, error) ClearKVInvoiceHashIndex(ctx context.Context) error + CountPayments(ctx context.Context) (int64, error) CountZombieChannels(ctx context.Context, version int16) (int64, error) CreateChannel(ctx context.Context, arg CreateChannelParams) (int64, error) DeleteCanceledInvoices(ctx context.Context) (sql.Result, error) @@ -31,10 +32,19 @@ type Querier interface { DeleteZombieChannel(ctx context.Context, arg DeleteZombieChannelParams) (sql.Result, error) FetchAMPSubInvoiceHTLCs(ctx context.Context, arg FetchAMPSubInvoiceHTLCsParams) ([]FetchAMPSubInvoiceHTLCsRow, error) FetchAMPSubInvoices(ctx context.Context, arg FetchAMPSubInvoicesParams) ([]AmpSubInvoice, error) + // Fetch all inflight attempts across all payments + FetchAllInflightAttempts(ctx context.Context) ([]PaymentHtlcAttempt, error) + FetchHopLevelCustomRecords(ctx context.Context, hopIds []int64) ([]PaymentHopCustomRecord, error) + FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]FetchHopsForAttemptsRow, error) + FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptsForPaymentsRow, error) + FetchPayment(ctx context.Context, paymentIdentifier []byte) (FetchPaymentRow, error) + FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIds []int64) ([]PaymentFirstHopCustomRecord, error) + FetchPaymentsByIDs(ctx context.Context, paymentIds []int64) ([]FetchPaymentsByIDsRow, error) // FetchPendingInvoices returns all invoices in a pending state (open or // accepted). The invoices_state_idx index on the state column makes this a // fast index scan rather than a full table scan. FetchPendingInvoices(ctx context.Context, arg FetchPendingInvoicesParams) ([]Invoice, error) + FetchRouteLevelFirstHopCustomRecords(ctx context.Context, htlcAttemptIndices []int64) ([]PaymentAttemptFirstHopCustomRecord, error) FetchSettledAMPSubInvoices(ctx context.Context, arg FetchSettledAMPSubInvoicesParams) ([]FetchSettledAMPSubInvoicesRow, error) // FilterInvoicesByAddIndex returns invoices whose add_index (primary key id) // is greater than or equal to the given value, ordered by id. Because id is @@ -58,6 +68,7 @@ type Querier interface { // It returns invoices in descending id order up to and including add_index_let. // See FilterInvoicesForward for the expected Go-side defaults. FilterInvoicesReverse(ctx context.Context, arg FilterInvoicesReverseParams) ([]Invoice, error) + FilterPayments(ctx context.Context, arg FilterPaymentsParams) ([]FilterPaymentsRow, error) GetAMPInvoiceID(ctx context.Context, setID []byte) (int64, error) GetChannelAndNodesBySCID(ctx context.Context, arg GetChannelAndNodesBySCIDParams) (GetChannelAndNodesBySCIDRow, error) GetChannelByOutpointWithPolicies(ctx context.Context, arg GetChannelByOutpointWithPoliciesParams) (GetChannelByOutpointWithPoliciesRow, error) diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql new file mode 100644 index 00000000000..ce43a3e2976 --- /dev/null +++ b/sqldb/sqlc/queries/payments.sql @@ -0,0 +1,153 @@ +/* ───────────────────────────────────────────── + fetch queries + ───────────────────────────────────────────── +*/ + +-- name: FilterPayments :many +SELECT + sqlc.embed(p), + i.intent_type AS "intent_type", + i.intent_payload AS "intent_payload" +FROM payments p +LEFT JOIN payment_intents i ON i.id = p.intent_id +WHERE ( + p.id > sqlc.narg('index_offset_get') OR + sqlc.narg('index_offset_get') IS NULL +) AND ( + p.id < sqlc.narg('index_offset_let') OR + sqlc.narg('index_offset_let') IS NULL +) AND ( + p.created_at >= sqlc.narg('created_after') OR + sqlc.narg('created_after') IS NULL +) AND ( + p.created_at <= sqlc.narg('created_before') OR + sqlc.narg('created_before') IS NULL +) AND ( + i.intent_type = sqlc.narg('intent_type') OR + sqlc.narg('intent_type') IS NULL OR i.intent_type IS NULL +) +ORDER BY + CASE WHEN sqlc.narg('reverse') = false OR sqlc.narg('reverse') IS NULL THEN p.id END ASC, + CASE WHEN sqlc.narg('reverse') = true THEN p.id END DESC +LIMIT @num_limit; + +-- name: FetchPayment :one +SELECT + sqlc.embed(p), + i.intent_type AS "intent_type", + i.intent_payload AS "intent_payload" +FROM payments p +LEFT JOIN payment_intents i ON i.id = p.intent_id +WHERE p.payment_identifier = $1; + +-- name: FetchPaymentsByIDs :many +SELECT + sqlc.embed(p), + i.intent_type AS "intent_type", + i.intent_payload AS "intent_payload" +FROM payments p +LEFT JOIN payment_intents i ON i.id = p.intent_id +WHERE p.id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/); + +-- name: CountPayments :one +SELECT COUNT(*) FROM payments; + +-- name: FetchHtlcAttemptsForPayments :many +SELECT + ha.id, + ha.attempt_index, + ha.payment_id, + ha.session_key, + ha.attempt_time, + ha.payment_hash, + ha.first_hop_amount_msat, + ha.route_total_time_lock, + ha.route_total_amount, + ha.route_source_key, + hr.resolution_type, + hr.resolution_time, + hr.failure_source_index, + hr.htlc_fail_reason, + hr.failure_msg, + hr.settle_preimage +FROM payment_htlc_attempts ha +LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index +WHERE ha.payment_id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/) +ORDER BY ha.payment_id ASC, ha.attempt_time ASC; + +-- name: FetchAllInflightAttempts :many +-- Fetch all inflight attempts across all payments +SELECT + ha.id, + ha.attempt_index, + ha.payment_id, + ha.session_key, + ha.attempt_time, + ha.payment_hash, + ha.first_hop_amount_msat, + ha.route_total_time_lock, + ha.route_total_amount, + ha.route_source_key +FROM payment_htlc_attempts ha +WHERE NOT EXISTS ( + SELECT 1 FROM payment_htlc_attempt_resolutions hr + WHERE hr.attempt_index = ha.attempt_index +) +ORDER BY ha.attempt_index ASC; + +-- name: FetchHopsForAttempts :many +SELECT + h.id, + h.htlc_attempt_index, + h.hop_index, + h.pub_key, + h.scid, + h.outgoing_time_lock, + h.amt_to_forward, + h.meta_data, + m.payment_addr AS mpp_payment_addr, + m.total_msat AS mpp_total_msat, + a.root_share AS amp_root_share, + a.set_id AS amp_set_id, + a.child_index AS amp_child_index, + b.encrypted_data, + b.blinding_point, + b.blinded_path_total_amt +FROM payment_route_hops h +LEFT JOIN payment_route_hop_mpp m ON m.hop_id = h.id +LEFT JOIN payment_route_hop_amp a ON a.hop_id = h.id +LEFT JOIN payment_route_hop_blinded b ON b.hop_id = h.id +WHERE h.htlc_attempt_index IN (sqlc.slice('htlc_attempt_indices')/*SLICE:htlc_attempt_indices*/) +ORDER BY h.htlc_attempt_index ASC, h.hop_index ASC; + + +-- name: FetchPaymentLevelFirstHopCustomRecords :many +SELECT + l.id, + l.payment_id, + l.key, + l.value +FROM payment_first_hop_custom_records l +WHERE l.payment_id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/) +ORDER BY l.payment_id ASC, l.key ASC; + +-- name: FetchRouteLevelFirstHopCustomRecords :many +SELECT + l.id, + l.htlc_attempt_index, + l.key, + l.value +FROM payment_attempt_first_hop_custom_records l +WHERE l.htlc_attempt_index IN (sqlc.slice('htlc_attempt_indices')/*SLICE:htlc_attempt_indices*/) +ORDER BY l.htlc_attempt_index ASC, l.key ASC; + +-- name: FetchHopLevelCustomRecords :many +SELECT + l.id, + l.hop_id, + l.key, + l.value +FROM payment_hop_custom_records l +WHERE l.hop_id IN (sqlc.slice('hop_ids')/*SLICE:hop_ids*/) +ORDER BY l.hop_id ASC, l.key ASC; + From eeca189b87d6515535e6814426858063e754e7b8 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 12 Oct 2025 19:15:26 +0200 Subject: [PATCH 07/78] paymentsdb: add new internal error --- payments/db/errors.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/payments/db/errors.go b/payments/db/errors.go index 6d5bd211ca6..fee71b05f59 100644 --- a/payments/db/errors.go +++ b/payments/db/errors.go @@ -136,4 +136,8 @@ var ( // NOTE: Only used for the kv backend. ErrNoSequenceNrIndex = errors.New("payment sequence number index " + "does not exist") + + // errMaxPaymentsReached is used internally to signal that the maximum + // number of payments has been reached during a paginated query. + errMaxPaymentsReached = errors.New("max payments reached") ) From e8fe45fe65756392feb4604b48c0c20b6efcbba1 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 12 Oct 2025 19:15:49 +0200 Subject: [PATCH 08/78] paymentsdb: implement QueryPayments for sql backend --- payments/db/sql_converters.go | 272 ++++++++++++++++ payments/db/sql_store.go | 569 ++++++++++++++++++++++++++++++++++ sqldb/sqlc/db_custom.go | 76 +++++ 3 files changed, 917 insertions(+) create mode 100644 payments/db/sql_converters.go diff --git a/payments/db/sql_converters.go b/payments/db/sql_converters.go new file mode 100644 index 00000000000..fd0cad2dcd1 --- /dev/null +++ b/payments/db/sql_converters.go @@ -0,0 +1,272 @@ +package paymentsdb + +import ( + "bytes" + "fmt" + "strconv" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/sqldb/sqlc" + "github.com/lightningnetwork/lnd/tlv" +) + +// dbPaymentToCreationInfo converts database payment data to the +// PaymentCreationInfo struct. +func dbPaymentToCreationInfo(paymentIdentifier []byte, amountMsat int64, + createdAt time.Time, intentPayload []byte, + firstHopCustomRecords lnwire.CustomRecords) *PaymentCreationInfo { + + // This is the payment hash for non-AMP payments and the SetID for AMP + // payments. + var identifier lntypes.Hash + copy(identifier[:], paymentIdentifier) + + return &PaymentCreationInfo{ + PaymentIdentifier: identifier, + Value: lnwire.MilliSatoshi(amountMsat), + CreationTime: createdAt.Local(), + PaymentRequest: intentPayload, + FirstHopCustomRecords: firstHopCustomRecords, + } +} + +// dbAttemptToHTLCAttempt converts a database HTLC attempt to an HTLCAttempt. +func dbAttemptToHTLCAttempt(dbAttempt sqlc.FetchHtlcAttemptsForPaymentsRow, + hops []sqlc.FetchHopsForAttemptsRow, + hopCustomRecords map[int64][]sqlc.PaymentHopCustomRecord, + routeCustomRecords []sqlc.PaymentAttemptFirstHopCustomRecord) ( + *HTLCAttempt, error) { + + // Convert route-level first hop custom records to CustomRecords map. + var firstHopWireCustomRecords lnwire.CustomRecords + if len(routeCustomRecords) > 0 { + firstHopWireCustomRecords = make(lnwire.CustomRecords) + for _, record := range routeCustomRecords { + firstHopWireCustomRecords[uint64(record.Key)] = + record.Value + } + } + + // Build the route from the database data. + route, err := dbDataToRoute( + hops, hopCustomRecords, dbAttempt.FirstHopAmountMsat, + dbAttempt.RouteTotalTimeLock, dbAttempt.RouteTotalAmount, + dbAttempt.RouteSourceKey, firstHopWireCustomRecords, + ) + if err != nil { + return nil, fmt.Errorf("failed to convert to route: %w", + err) + } + + hash, err := lntypes.MakeHash(dbAttempt.PaymentHash) + if err != nil { + return nil, fmt.Errorf("failed to parse payment "+ + "hash: %w", err) + } + + // Create the attempt info. + var sessionKey [32]byte + copy(sessionKey[:], dbAttempt.SessionKey) + + info := HTLCAttemptInfo{ + AttemptID: uint64(dbAttempt.AttemptIndex), + sessionKey: sessionKey, + Route: *route, + AttemptTime: dbAttempt.AttemptTime, + Hash: &hash, + } + + attempt := &HTLCAttempt{ + HTLCAttemptInfo: info, + } + + // If there's no resolution type, the attempt is still in-flight. + // Return early without processing settlement or failure info. + if !dbAttempt.ResolutionType.Valid { + return attempt, nil + } + + // Add settlement info if present. + if HTLCAttemptResolutionType(dbAttempt.ResolutionType.Int32) == + HTLCAttemptResolutionSettled { + + var preimage lntypes.Preimage + copy(preimage[:], dbAttempt.SettlePreimage) + + attempt.Settle = &HTLCSettleInfo{ + Preimage: preimage, + SettleTime: dbAttempt.ResolutionTime.Time, + } + } + + // Add failure info if present. + if HTLCAttemptResolutionType(dbAttempt.ResolutionType.Int32) == + HTLCAttemptResolutionFailed { + + failure := &HTLCFailInfo{ + FailTime: dbAttempt.ResolutionTime.Time, + } + + if dbAttempt.HtlcFailReason.Valid { + failure.Reason = HTLCFailReason( + dbAttempt.HtlcFailReason.Int32, + ) + } + + if dbAttempt.FailureSourceIndex.Valid { + failure.FailureSourceIndex = uint32( + dbAttempt.FailureSourceIndex.Int32, + ) + } + + // Decode the failure message if present. + if len(dbAttempt.FailureMsg) > 0 { + msg, err := lnwire.DecodeFailureMessage( + bytes.NewReader(dbAttempt.FailureMsg), 0, + ) + if err != nil { + return nil, fmt.Errorf("failed to decode "+ + "failure message: %w", err) + } + failure.Message = msg + } + + attempt.Failure = failure + } + + return attempt, nil +} + +// dbDataToRoute converts database route data to a route.Route. +func dbDataToRoute(hops []sqlc.FetchHopsForAttemptsRow, + hopCustomRecords map[int64][]sqlc.PaymentHopCustomRecord, + firstHopAmountMsat int64, totalTimeLock int32, totalAmount int64, + sourceKey []byte, firstHopWireCustomRecords lnwire.CustomRecords) ( + *route.Route, error) { + + if len(hops) == 0 { + return nil, fmt.Errorf("no hops provided") + } + + // Hops are already sorted by hop_index from the SQL query. + routeHops := make([]*route.Hop, len(hops)) + + for i, hop := range hops { + pubKey, err := route.NewVertexFromBytes(hop.PubKey) + if err != nil { + return nil, fmt.Errorf("failed to parse pub key: %w", + err) + } + + var channelID uint64 + if hop.Scid != "" { + // The SCID is stored as a string representation + // of the uint64. + var err error + channelID, err = strconv.ParseUint(hop.Scid, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse "+ + "scid: %w", err) + } + } + + routeHop := &route.Hop{ + PubKeyBytes: pubKey, + ChannelID: channelID, + OutgoingTimeLock: uint32(hop.OutgoingTimeLock), + AmtToForward: lnwire.MilliSatoshi(hop.AmtToForward), + } + + // Add MPP record if present. + if len(hop.MppPaymentAddr) > 0 { + var paymentAddr [32]byte + copy(paymentAddr[:], hop.MppPaymentAddr) + routeHop.MPP = record.NewMPP( + lnwire.MilliSatoshi(hop.MppTotalMsat.Int64), + paymentAddr, + ) + } + + // Add AMP record if present. + if len(hop.AmpRootShare) > 0 { + var rootShare [32]byte + copy(rootShare[:], hop.AmpRootShare) + var setID [32]byte + copy(setID[:], hop.AmpSetID) + + routeHop.AMP = record.NewAMP( + rootShare, setID, + uint32(hop.AmpChildIndex.Int32), + ) + } + + // Add blinding point if present (only for introduction node). + if len(hop.BlindingPoint) > 0 { + pubKey, err := btcec.ParsePubKey(hop.BlindingPoint) + if err != nil { + return nil, fmt.Errorf("failed to parse "+ + "blinding point: %w", err) + } + routeHop.BlindingPoint = pubKey + } + + // Add encrypted data if present (for all blinded hops). + if len(hop.EncryptedData) > 0 { + routeHop.EncryptedData = hop.EncryptedData + } + + // Add total amount if present (only for final hop in blinded + // route). + if hop.BlindedPathTotalAmt.Valid { + routeHop.TotalAmtMsat = lnwire.MilliSatoshi( + hop.BlindedPathTotalAmt.Int64, + ) + } + + // Add hop-level custom records. + if records, ok := hopCustomRecords[hop.ID]; ok { + routeHop.CustomRecords = make( + record.CustomSet, + ) + for _, rec := range records { + routeHop.CustomRecords[uint64(rec.Key)] = + rec.Value + } + } + + // Add metadata if present. + if len(hop.MetaData) > 0 { + routeHop.Metadata = hop.MetaData + } + + routeHops[i] = routeHop + } + + // Parse the source node public key. + var sourceNode route.Vertex + copy(sourceNode[:], sourceKey) + + route := &route.Route{ + TotalTimeLock: uint32(totalTimeLock), + TotalAmount: lnwire.MilliSatoshi(totalAmount), + SourcePubKey: sourceNode, + Hops: routeHops, + FirstHopWireCustomRecords: firstHopWireCustomRecords, + } + + // Set the first hop amount if it is set. + if firstHopAmountMsat != 0 { + route.FirstHopAmount = tlv.NewRecordT[tlv.TlvType0]( + tlv.NewBigSizeT(lnwire.MilliSatoshi( + firstHopAmountMsat, + )), + ) + } + + return route, nil +} diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index ced061afafa..b0ce408f924 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -2,14 +2,40 @@ package paymentsdb import ( "context" + "errors" "fmt" + "math" + "time" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/sqldb" "github.com/lightningnetwork/lnd/sqldb/sqlc" ) +// PaymentIntentType represents the type of payment intent. +type PaymentIntentType int16 + +const ( + // PaymentIntentTypeBolt11 indicates a BOLT11 invoice payment. + PaymentIntentTypeBolt11 PaymentIntentType = 0 +) + +// HTLCAttemptResolutionType represents the type of HTLC attempt resolution. +type HTLCAttemptResolutionType int32 + +const ( + // HTLCAttemptResolutionSettled indicates the HTLC attempt was settled + // successfully with a preimage. + HTLCAttemptResolutionSettled HTLCAttemptResolutionType = 1 + + // HTLCAttemptResolutionFailed indicates the HTLC attempt failed. + HTLCAttemptResolutionFailed HTLCAttemptResolutionType = 2 +) + // SQLQueries is a subset of the sqlc.Querier interface that can be used to // execute queries against the SQL payments tables. +// +//nolint:ll type SQLQueries interface { /* Payment DB read operations. @@ -83,3 +109,546 @@ func NewSQLStore(cfg *SQLStoreConfig, db BatchedSQLQueries, // A compile-time constraint to ensure SQLStore implements DB. var _ DB = (*SQLStore)(nil) + +// fetchPaymentWithCompleteData fetches a payment with all its related data +// including attempts, hops, and custom records from the database. +// This is a convenience wrapper around the batch loading functions for single +// payment operations. +func (s *SQLStore) fetchPaymentWithCompleteData(ctx context.Context, + db SQLQueries, dbPayment sqlc.PaymentAndIntent) (*MPPayment, error) { + + payment := dbPayment.GetPayment() + + // Load batch data for this single payment. + batchData, err := s.loadPaymentsBatchData(ctx, db, []int64{payment.ID}) + if err != nil { + return nil, fmt.Errorf("failed to load batch data: %w", err) + } + + // Build the payment from the batch data. + return s.buildPaymentFromBatchData(dbPayment, batchData) +} + +// paymentsBatchData holds all the batch-loaded data for multiple payments. +type paymentsBatchData struct { + // paymentCustomRecords maps payment ID to its custom records. + paymentCustomRecords map[int64][]sqlc.PaymentFirstHopCustomRecord + + // attempts maps payment ID to its HTLC attempts. + attempts map[int64][]sqlc.FetchHtlcAttemptsForPaymentsRow + + // hopsByAttempt maps attempt index to its hops. + hopsByAttempt map[int64][]sqlc.FetchHopsForAttemptsRow + + // hopCustomRecords maps hop ID to its custom records. + hopCustomRecords map[int64][]sqlc.PaymentHopCustomRecord + + // routeCustomRecords maps attempt index to its route-level custom + // records. + routeCustomRecords map[int64][]sqlc.PaymentAttemptFirstHopCustomRecord +} + +// loadPaymentCustomRecords loads payment-level custom records for a given +// set of payment IDs. +func (s *SQLStore) loadPaymentCustomRecords(ctx context.Context, + db SQLQueries, paymentIDs []int64, + batchData *paymentsBatchData) error { + + return sqldb.ExecuteBatchQuery( + ctx, s.cfg.QueryCfg, paymentIDs, + func(id int64) int64 { return id }, + func(ctx context.Context, ids []int64) ( + []sqlc.PaymentFirstHopCustomRecord, error) { + + //nolint:ll + records, err := db.FetchPaymentLevelFirstHopCustomRecords( + ctx, ids, + ) + + return records, err + }, + func(ctx context.Context, + record sqlc.PaymentFirstHopCustomRecord) error { + + paymentRecords := + batchData.paymentCustomRecords[record.PaymentID] + + batchData.paymentCustomRecords[record.PaymentID] = + append(paymentRecords, record) + + return nil + }, + ) +} + +// loadHtlcAttempts loads HTLC attempts for all payments and returns all +// attempt indices. +func (s *SQLStore) loadHtlcAttempts(ctx context.Context, db SQLQueries, + paymentIDs []int64, batchData *paymentsBatchData) ([]int64, error) { + + var allAttemptIndices []int64 + + err := sqldb.ExecuteBatchQuery( + ctx, s.cfg.QueryCfg, paymentIDs, + func(id int64) int64 { return id }, + func(ctx context.Context, ids []int64) ( + []sqlc.FetchHtlcAttemptsForPaymentsRow, error) { + + return db.FetchHtlcAttemptsForPayments(ctx, ids) + }, + func(ctx context.Context, + attempt sqlc.FetchHtlcAttemptsForPaymentsRow) error { + + batchData.attempts[attempt.PaymentID] = append( + batchData.attempts[attempt.PaymentID], attempt, + ) + allAttemptIndices = append( + allAttemptIndices, attempt.AttemptIndex, + ) + + return nil + }, + ) + + return allAttemptIndices, err +} + +// loadHopsForAttempts loads hops for all attempts and returns all hop IDs. +func (s *SQLStore) loadHopsForAttempts(ctx context.Context, db SQLQueries, + attemptIndices []int64, batchData *paymentsBatchData) ([]int64, error) { + + var hopIDs []int64 + + err := sqldb.ExecuteBatchQuery( + ctx, s.cfg.QueryCfg, attemptIndices, + func(idx int64) int64 { return idx }, + func(ctx context.Context, indices []int64) ( + []sqlc.FetchHopsForAttemptsRow, error) { + + return db.FetchHopsForAttempts(ctx, indices) + }, + func(ctx context.Context, + hop sqlc.FetchHopsForAttemptsRow) error { + + attemptHops := + batchData.hopsByAttempt[hop.HtlcAttemptIndex] + + batchData.hopsByAttempt[hop.HtlcAttemptIndex] = + append(attemptHops, hop) + + hopIDs = append(hopIDs, hop.ID) + + return nil + }, + ) + + return hopIDs, err +} + +// loadHopCustomRecords loads hop-level custom records for all hops. +func (s *SQLStore) loadHopCustomRecords(ctx context.Context, db SQLQueries, + hopIDs []int64, batchData *paymentsBatchData) error { + + return sqldb.ExecuteBatchQuery( + ctx, s.cfg.QueryCfg, hopIDs, + func(id int64) int64 { return id }, + func(ctx context.Context, ids []int64) ( + []sqlc.PaymentHopCustomRecord, error) { + + return db.FetchHopLevelCustomRecords(ctx, ids) + }, + func(ctx context.Context, + record sqlc.PaymentHopCustomRecord) error { + + // TODO(ziggie): Can we get rid of this? + // This has to be in place otherwise the + // comparison will not match. + if record.Value == nil { + record.Value = []byte{} + } + + batchData.hopCustomRecords[record.HopID] = append( + batchData.hopCustomRecords[record.HopID], + record, + ) + + return nil + }, + ) +} + +// loadRouteCustomRecords loads route-level first hop custom records for all +// attempts. +func (s *SQLStore) loadRouteCustomRecords(ctx context.Context, db SQLQueries, + attemptIndices []int64, batchData *paymentsBatchData) error { + + return sqldb.ExecuteBatchQuery( + ctx, s.cfg.QueryCfg, attemptIndices, + func(idx int64) int64 { return idx }, + func(ctx context.Context, indices []int64) ( + []sqlc.PaymentAttemptFirstHopCustomRecord, error) { + + return db.FetchRouteLevelFirstHopCustomRecords( + ctx, indices, + ) + }, + func(ctx context.Context, + record sqlc.PaymentAttemptFirstHopCustomRecord) error { + + idx := record.HtlcAttemptIndex + attemptRecords := batchData.routeCustomRecords[idx] + + batchData.routeCustomRecords[idx] = + append(attemptRecords, record) + + return nil + }, + ) +} + +// loadPaymentsBatchData loads all related data for multiple payments in batch. +func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, + paymentIDs []int64) (*paymentsBatchData, error) { + + batchData := &paymentsBatchData{ + paymentCustomRecords: make( + map[int64][]sqlc.PaymentFirstHopCustomRecord, + ), + attempts: make( + map[int64][]sqlc.FetchHtlcAttemptsForPaymentsRow, + ), + hopsByAttempt: make( + map[int64][]sqlc.FetchHopsForAttemptsRow, + ), + hopCustomRecords: make( + map[int64][]sqlc.PaymentHopCustomRecord, + ), + routeCustomRecords: make( + map[int64][]sqlc.PaymentAttemptFirstHopCustomRecord, + ), + } + + if len(paymentIDs) == 0 { + return batchData, nil + } + + // Load payment-level custom records. + err := s.loadPaymentCustomRecords(ctx, db, paymentIDs, batchData) + if err != nil { + return nil, fmt.Errorf("failed to fetch payment custom "+ + "records: %w", err) + } + + // Load HTLC attempts and collect attempt indices. + allAttemptIndices, err := s.loadHtlcAttempts( + ctx, db, paymentIDs, batchData, + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch HTLC attempts: %w", + err) + } + + if len(allAttemptIndices) == 0 { + // No attempts, return early. + return batchData, nil + } + + // Load hops for all attempts and collect hop IDs. + hopIDs, err := s.loadHopsForAttempts( + ctx, db, allAttemptIndices, batchData, + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch hops for attempts: %w", + err) + } + + // Load hop-level custom records if there are any hops. + if len(hopIDs) > 0 { + err = s.loadHopCustomRecords(ctx, db, hopIDs, batchData) + if err != nil { + return nil, fmt.Errorf("failed to fetch hop custom "+ + "records: %w", err) + } + } + + // Load route-level first hop custom records. + err = s.loadRouteCustomRecords(ctx, db, allAttemptIndices, batchData) + if err != nil { + return nil, fmt.Errorf("failed to fetch route custom "+ + "records: %w", err) + } + + return batchData, nil +} + +// buildPaymentFromBatchData builds a complete MPPayment from a database payment +// and pre-loaded batch data. +func (s *SQLStore) buildPaymentFromBatchData(dbPayment sqlc.PaymentAndIntent, + batchData *paymentsBatchData) (*MPPayment, error) { + + // The query will only return BOLT 11 payment intents or intents with + // no intent type set. + paymentIntent := dbPayment.GetPaymentIntent() + paymentRequest := paymentIntent.IntentPayload + + payment := dbPayment.GetPayment() + + // Get payment-level custom records from batch data. + customRecords := batchData.paymentCustomRecords[payment.ID] + + // Convert to the FirstHopCustomRecords map. + var firstHopCustomRecords lnwire.CustomRecords + if len(customRecords) > 0 { + firstHopCustomRecords = make(lnwire.CustomRecords) + for _, record := range customRecords { + firstHopCustomRecords[uint64(record.Key)] = record.Value + } + } + + // Convert database payment data to the PaymentCreationInfo struct. + info := dbPaymentToCreationInfo( + payment.PaymentIdentifier, payment.AmountMsat, + payment.CreatedAt, paymentRequest, firstHopCustomRecords, + ) + + // Get all HTLC attempts from batch data for a given payment. + dbAttempts := batchData.attempts[payment.ID] + + // Convert all attempts to HTLCAttempt structs using the pre-loaded + // batch data. + attempts := make([]HTLCAttempt, 0, len(dbAttempts)) + for _, dbAttempt := range dbAttempts { + attemptIndex := dbAttempt.AttemptIndex + // Convert the batch row type to the single row type. + attempt, err := dbAttemptToHTLCAttempt( + dbAttempt, batchData.hopsByAttempt[attemptIndex], + batchData.hopCustomRecords, + batchData.routeCustomRecords[attemptIndex], + ) + if err != nil { + return nil, fmt.Errorf("failed to convert attempt "+ + "%d: %w", attemptIndex, err) + } + attempts = append(attempts, *attempt) + } + + // Set the failure reason if present. + // + // TODO(ziggie): Rename it to Payment Memo in the database? + var failureReason *FailureReason + if payment.FailReason.Valid { + reason := FailureReason(payment.FailReason.Int32) + failureReason = &reason + } + + mpPayment := &MPPayment{ + SequenceNum: uint64(payment.ID), + Info: info, + HTLCs: attempts, + FailureReason: failureReason, + } + + // The status and state will be determined by calling + // SetState after construction. + if err := mpPayment.SetState(); err != nil { + return nil, fmt.Errorf("failed to set payment state: %w", err) + } + + return mpPayment, nil +} + +// QueryPayments queries and retrieves payments from the database with support +// for filtering, pagination, and efficient batch loading of related data. +// +// The function accepts a Query parameter that controls: +// - Pagination: IndexOffset specifies where to start (exclusive), and +// MaxPayments limits the number of results returned +// - Ordering: Reversed flag determines if results are returned in reverse +// chronological order +// - Filtering: CreationDateStart/End filter by creation time, and +// IncludeIncomplete controls whether non-succeeded payments are included +// - Metadata: CountTotal flag determines if the total payment count should +// be calculated +// +// The function optimizes performance by loading all related data (HTLCs, +// sequences, failure reasons, etc.) for multiple payments in a single batch +// query, rather than fetching each payment's data individually. +// +// Returns a Response containing: +// - Payments: the list of matching payments with complete data +// - FirstIndexOffset/LastIndexOffset: pagination cursors for the first and +// last payment in the result set +// - TotalCount: total number of payments in the database (if CountTotal was +// requested, otherwise 0) +// +// This is part of the DB interface. +func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, + error) { + + if query.MaxPayments == 0 { + return Response{}, fmt.Errorf("max payments must be non-zero") + } + + var ( + allPayments []*MPPayment + totalCount int64 + initialCursor int64 + ) + + extractCursor := func( + row sqlc.FilterPaymentsRow) int64 { + + return row.Payment.ID + } + + err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { + // We first count all payments to determine the total count + // if requested. + if query.CountTotal { + totalPayments, err := db.CountPayments(ctx) + if err != nil { + return fmt.Errorf("failed to count "+ + "payments: %w", err) + } + totalCount = totalPayments + } + + // collectFunc extracts the payment ID from each payment row. + collectFunc := func(row sqlc.FilterPaymentsRow) (int64, + error) { + + return row.Payment.ID, nil + } + + // batchDataFunc loads all related data for a batch of payments. + batchDataFunc := func(ctx context.Context, paymentIDs []int64) ( + *paymentsBatchData, error) { + + return s.loadPaymentsBatchData(ctx, db, paymentIDs) + } + + // processPayment processes each payment with the batch-loaded + // data. + processPayment := func(ctx context.Context, + dbPayment sqlc.FilterPaymentsRow, + batchData *paymentsBatchData) error { + + // Build the payment from the pre-loaded batch data. + mpPayment, err := s.buildPaymentFromBatchData( + dbPayment, batchData, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment "+ + "with complete data: %w", err) + } + + // To keep compatibility with the old API, we only + // return non-succeeded payments if requested. + if mpPayment.Status != StatusSucceeded && + !query.IncludeIncomplete { + + return nil + } + + if uint64(len(allPayments)) >= query.MaxPayments { + return errMaxPaymentsReached + } + + allPayments = append(allPayments, mpPayment) + + return nil + } + + queryFunc := func(ctx context.Context, lastID int64, + limit int32) ([]sqlc.FilterPaymentsRow, error) { + + filterParams := sqlc.FilterPaymentsParams{ + NumLimit: limit, + Reverse: query.Reversed, + // For now there only BOLT 11 payment intents + // exist. + IntentType: sqldb.SQLInt16( + PaymentIntentTypeBolt11, + ), + } + + if query.Reversed { + filterParams.IndexOffsetLet = sqldb.SQLInt64( + lastID, + ) + } else { + filterParams.IndexOffsetGet = sqldb.SQLInt64( + lastID, + ) + } + + // Add potential date filters if specified. + if query.CreationDateStart != 0 { + filterParams.CreatedAfter = sqldb.SQLTime( + time.Unix(query.CreationDateStart, 0). + UTC(), + ) + } + if query.CreationDateEnd != 0 { + filterParams.CreatedBefore = sqldb.SQLTime( + time.Unix(query.CreationDateEnd, 0). + UTC(), + ) + } + + return db.FilterPayments(ctx, filterParams) + } + + if query.Reversed { + if query.IndexOffset == 0 { + initialCursor = int64(math.MaxInt64) + } else { + initialCursor = int64(query.IndexOffset) + } + } else { + initialCursor = int64(query.IndexOffset) + } + + return sqldb.ExecuteCollectAndBatchWithSharedDataQuery( + ctx, s.cfg.QueryCfg, initialCursor, queryFunc, + extractCursor, collectFunc, batchDataFunc, + processPayment, + ) + }, func() { + allPayments = nil + }) + + // We make sure we don't return an error if we reached the maximum + // number of payments. Which is the pagination limit for the query + // itself. + if err != nil && !errors.Is(err, errMaxPaymentsReached) { + return Response{}, fmt.Errorf("failed to query payments: %w", + err) + } + + // Handle case where no payments were found + if len(allPayments) == 0 { + return Response{ + Payments: allPayments, + FirstIndexOffset: 0, + LastIndexOffset: 0, + TotalCount: uint64(totalCount), + }, nil + } + + // If the query was reversed, we need to reverse the payment list + // to match the kvstore behavior and return payments in forward order. + if query.Reversed { + for i, j := 0, len(allPayments)-1; i < j; i, j = i+1, j-1 { + allPayments[i], allPayments[j] = allPayments[j], + allPayments[i] + } + } + + return Response{ + Payments: allPayments, + FirstIndexOffset: allPayments[0].SequenceNum, + LastIndexOffset: allPayments[len(allPayments)-1].SequenceNum, + TotalCount: uint64(totalCount), + }, nil +} diff --git a/sqldb/sqlc/db_custom.go b/sqldb/sqlc/db_custom.go index d4feafe21b8..7888f81f45d 100644 --- a/sqldb/sqlc/db_custom.go +++ b/sqldb/sqlc/db_custom.go @@ -167,3 +167,79 @@ func (r GetChannelsBySCIDRangeRow) Node1Pub() []byte { func (r GetChannelsBySCIDRangeRow) Node2Pub() []byte { return r.Node2PubKey } + +// PaymentAndIntent is an interface that provides access to a payment and its +// associated payment intent. +type PaymentAndIntent interface { + // GetPayment returns the Payment associated with this interface. + GetPayment() Payment + + // GetPaymentIntent returns the PaymentIntent associated with this payment. + GetPaymentIntent() PaymentIntent +} + +// GetPayment returns the Payment associated with this interface. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FilterPaymentsRow) GetPayment() Payment { + return r.Payment +} + +// GetPaymentIntent returns the PaymentIntent associated with this payment. +// If the payment has no intent (IntentType is NULL), this returns a zero-value +// PaymentIntent. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FilterPaymentsRow) GetPaymentIntent() PaymentIntent { + if !r.IntentType.Valid { + return PaymentIntent{} + } + return PaymentIntent{ + IntentType: r.IntentType.Int16, + IntentPayload: r.IntentPayload, + } +} + +// GetPayment returns the Payment associated with this interface. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FetchPaymentRow) GetPayment() Payment { + return r.Payment +} + +// GetPaymentIntent returns the PaymentIntent associated with this payment. +// If the payment has no intent (IntentType is NULL), this returns a zero-value +// PaymentIntent. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FetchPaymentRow) GetPaymentIntent() PaymentIntent { + if !r.IntentType.Valid { + return PaymentIntent{} + } + return PaymentIntent{ + IntentType: r.IntentType.Int16, + IntentPayload: r.IntentPayload, + } +} + +// GetPayment returns the Payment associated with this interface. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FetchPaymentsByIDsRow) GetPayment() Payment { + return r.Payment +} + +// GetPaymentIntent returns the PaymentIntent associated with this payment. +// If the payment has no intent (IntentType is NULL), this returns a zero-value +// PaymentIntent. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FetchPaymentsByIDsRow) GetPaymentIntent() PaymentIntent { + if !r.IntentType.Valid { + return PaymentIntent{} + } + return PaymentIntent{ + IntentType: r.IntentType.Int16, + IntentPayload: r.IntentPayload, + } +} From 8aae9ffc2ce1146d9b76873cec2dcd030c7f6e0d Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 14:53:07 +0200 Subject: [PATCH 09/78] paymentsdb: implement FetchPayment for sql backend --- payments/db/sql_store.go | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index b0ce408f924..1b7bfbacb7b 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -2,11 +2,13 @@ package paymentsdb import ( "context" + "database/sql" "errors" "fmt" "math" "time" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/sqldb" "github.com/lightningnetwork/lnd/sqldb/sqlc" @@ -652,3 +654,44 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, TotalCount: uint64(totalCount), }, nil } + +// FetchPayment retrieves a complete payment record from the database by its +// payment hash. The returned MPPayment includes all payment metadata such as +// creation info, payment status, current state, all HTLC attempts (both +// successful and failed), and the failure reason if the payment has been +// marked as failed. +// +// Returns ErrPaymentNotInitiated if no payment with the given hash exists. +// +// This is part of the DB interface. +func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { + ctx := context.TODO() + + var mpPayment *MPPayment + + err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to fetch payment: %w", err) + } + + if errors.Is(err, sql.ErrNoRows) { + return ErrPaymentNotInitiated + } + + mpPayment, err = s.fetchPaymentWithCompleteData( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment with "+ + "complete data: %w", err) + } + + return nil + }, sqldb.NoOpReset) + if err != nil { + return nil, err + } + + return mpPayment, nil +} From 4c5eaad60635a19a8959a93fa49600736c0fb25c Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 12 Oct 2025 19:35:55 +0200 Subject: [PATCH 10/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index b7d969acd31..e01f35c9379 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -203,6 +203,8 @@ refacotor the payment related LND code to make it more modular. * Implement the SQL backend for the [payments database](https://github.com/lightningnetwork/lnd/pull/9147) + * Implement query methods (QueryPayments,FetchPayment) for the [payments db + SQL Backend](https://github.com/lightningnetwork/lnd/pull/10287) ## Code Health From f8241748a135c5d4e017430577912cbd7e37728e Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 11 Nov 2025 09:55:19 +0100 Subject: [PATCH 11/78] paymentsdb: enhance some godoc function descriptions --- payments/db/sql_store.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 1b7bfbacb7b..72fac39d5bf 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -151,7 +151,8 @@ type paymentsBatchData struct { } // loadPaymentCustomRecords loads payment-level custom records for a given -// set of payment IDs. +// set of payment IDs. It uses a batch query to fetch all custom records for +// the given payment IDs. func (s *SQLStore) loadPaymentCustomRecords(ctx context.Context, db SQLQueries, paymentIDs []int64, batchData *paymentsBatchData) error { @@ -184,7 +185,8 @@ func (s *SQLStore) loadPaymentCustomRecords(ctx context.Context, } // loadHtlcAttempts loads HTLC attempts for all payments and returns all -// attempt indices. +// attempt indices. It uses a batch query to fetch all attempts for the given +// payment IDs. func (s *SQLStore) loadHtlcAttempts(ctx context.Context, db SQLQueries, paymentIDs []int64, batchData *paymentsBatchData) ([]int64, error) { @@ -216,6 +218,7 @@ func (s *SQLStore) loadHtlcAttempts(ctx context.Context, db SQLQueries, } // loadHopsForAttempts loads hops for all attempts and returns all hop IDs. +// It uses a batch query to fetch all hops for the given attempt indices. func (s *SQLStore) loadHopsForAttempts(ctx context.Context, db SQLQueries, attemptIndices []int64, batchData *paymentsBatchData) ([]int64, error) { @@ -247,7 +250,8 @@ func (s *SQLStore) loadHopsForAttempts(ctx context.Context, db SQLQueries, return hopIDs, err } -// loadHopCustomRecords loads hop-level custom records for all hops. +// loadHopCustomRecords loads hop-level custom records for all hops. It uses +// a batch query to fetch all custom records for the given hop IDs. func (s *SQLStore) loadHopCustomRecords(ctx context.Context, db SQLQueries, hopIDs []int64, batchData *paymentsBatchData) error { @@ -280,7 +284,8 @@ func (s *SQLStore) loadHopCustomRecords(ctx context.Context, db SQLQueries, } // loadRouteCustomRecords loads route-level first hop custom records for all -// attempts. +// attempts. It uses a batch query to fetch all custom records for the given +// attempt indices. func (s *SQLStore) loadRouteCustomRecords(ctx context.Context, db SQLQueries, attemptIndices []int64, batchData *paymentsBatchData) error { @@ -309,6 +314,7 @@ func (s *SQLStore) loadRouteCustomRecords(ctx context.Context, db SQLQueries, } // loadPaymentsBatchData loads all related data for multiple payments in batch. +// It uses a batch queries to fetch all data for the given payment IDs. func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, paymentIDs []int64) (*paymentsBatchData, error) { From 27071acb6bffcdcc49d563eb73e9938854485fb3 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 11 Nov 2025 09:45:32 +0100 Subject: [PATCH 12/78] sqldb: Change payment_intent relationship to payment table Previously a one(intent)-to-many(payment) relationship it is now changed to a one-to-one relationship because a payment request only can have 1 payment related to it. Looking into the future with BOLT12 offers, the fetched invoice from the offer could be stored here as well and the relationship would still hold. --- sqldb/sqlc/migrations/000009_payments.up.sql | 82 ++++++++++---------- sqldb/sqlc/models.go | 2 +- sqldb/sqlc/payments.sql.go | 15 ++-- sqldb/sqlc/queries/payments.sql | 6 +- 4 files changed, 53 insertions(+), 52 deletions(-) diff --git a/sqldb/sqlc/migrations/000009_payments.up.sql b/sqldb/sqlc/migrations/000009_payments.up.sql index 0d85b497b00..65094a15e4c 100644 --- a/sqldb/sqlc/migrations/000009_payments.up.sql +++ b/sqldb/sqlc/migrations/000009_payments.up.sql @@ -2,43 +2,12 @@ -- Payment System Schema Migration -- ───────────────────────────────────────────── -- This migration creates the complete payment system schema including: --- - Payment intents (BOLT 11/12 invoices, offers) +-- - Payment intents (only BOLT 11 invoices for now) -- - Payment attempts and HTLC tracking -- - Route hops and custom TLV records -- - Resolution tracking for settled/failed payments -- ───────────────────────────────────────────── --- ───────────────────────────────────────────── --- Payment Intents Table --- ───────────────────────────────────────────── --- Stores the descriptor of what the payment is paying for. --- Depending on the type, the payload might contain: --- - BOLT 11 invoice data --- - BOLT 12 offer data --- - NULL for legacy hash-only/keysend style payments --- ───────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS payment_intents ( - -- Primary key for the intent record - id INTEGER PRIMARY KEY, - - -- The type of intent (e.g. 0 = bolt11_invoice, 1 = bolt12_offer) - -- Uses SMALLINT (int16) for efficient storage of enum values - intent_type SMALLINT NOT NULL, - - -- The serialized payload for the payment intent - -- Content depends on type - could be invoice, offer, or NULL - intent_payload BLOB -); - --- Index for efficient querying by intent type -CREATE INDEX IF NOT EXISTS idx_payment_intents_type -ON payment_intents(intent_type); - --- Unique constraint for deduplication of payment intents -CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_intents_unique -ON payment_intents(intent_type, intent_payload); - -- ───────────────────────────────────────────── -- Payments Table -- ───────────────────────────────────────────── @@ -55,10 +24,6 @@ CREATE TABLE IF NOT EXISTS payments ( -- Primary key for the payment record id INTEGER PRIMARY KEY, - -- Optional reference to the payment intent this payment was derived from - -- Links to BOLT 11 invoice, BOLT 12 offer, etc. - intent_id BIGINT REFERENCES payment_intents (id), - -- The amount of the payment in millisatoshis amount_msat BIGINT NOT NULL, @@ -70,20 +35,59 @@ CREATE TABLE IF NOT EXISTS payments ( -- For AMP: the setID -- For future intent types: any unique payment-level key payment_identifier BLOB NOT NULL, - + -- The reason for payment failure (only set if payment has failed) -- Integer enum type indicating failure reason fail_reason INTEGER, -- Ensure payment identifiers are unique across all payments - CONSTRAINT idx_payments_payment_identifier_unique + CONSTRAINT idx_payments_payment_identifier_unique UNIQUE (payment_identifier) ); -- Index for efficient querying by creation time (for chronological ordering) -CREATE INDEX IF NOT EXISTS idx_payments_created_at +CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at); +-- ───────────────────────────────────────────── +-- Payment Intents Table +-- ───────────────────────────────────────────── +-- Stores the descriptor of what the payment is paying for. +-- Depending on the type, the payload might contain: +-- - BOLT 11 invoice data +-- - BOLT 12 offer data +-- - NULL for legacy hash-only/keysend style payments +-- ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_intents ( + -- Primary key for the intent record + id INTEGER PRIMARY KEY, + + -- Reference to the payment this intent belongs to (one-to-one relationship) + -- When the payment is deleted, the intent is automatically deleted + payment_id BIGINT NOT NULL REFERENCES payments (id) ON DELETE CASCADE, + + -- The type of intent (e.g. 0 = bolt11_invoice, 1 = bolt12_invoice) + -- Uses SMALLINT (int16) for efficient storage of enum values + intent_type SMALLINT NOT NULL, + + -- The serialized payload for the payment intent + -- Content depends on type - could be invoice, offer, or NULL + intent_payload BLOB, + + -- Ensure one-to-one relationship: each payment has at most one intent. + -- Currently we only support one intent per payment this makes sure we do + -- not accidentally pay the same request multiple times. This currently + -- only has bolt 11 payment requests/invoices. But in the future this can + -- also include BOLT 12 offers/invoices. + CONSTRAINT idx_payment_intents_payment_id_unique + UNIQUE (payment_id) +); + +-- Index for efficient querying by intent type +CREATE INDEX IF NOT EXISTS idx_payment_intents_type +ON payment_intents(intent_type); + -- ───────────────────────────────────────────── -- Payment HTLC Attempts Table -- ───────────────────────────────────────────── diff --git a/sqldb/sqlc/models.go b/sqldb/sqlc/models.go index 899c572e549..0e04cd467da 100644 --- a/sqldb/sqlc/models.go +++ b/sqldb/sqlc/models.go @@ -211,7 +211,6 @@ type MigrationTracker struct { type Payment struct { ID int64 - IntentID sql.NullInt64 AmountMsat int64 CreatedAt time.Time PaymentIdentifier []byte @@ -264,6 +263,7 @@ type PaymentHtlcAttemptResolution struct { type PaymentIntent struct { ID int64 + PaymentID int64 IntentType int16 IntentPayload []byte } diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index 83c1c7f1f04..e28e8a52431 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -317,11 +317,11 @@ func (q *Queries) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds [ const fetchPayment = `-- name: FetchPayment :one SELECT - p.id, p.intent_id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, + p.id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, i.intent_type AS "intent_type", i.intent_payload AS "intent_payload" FROM payments p -LEFT JOIN payment_intents i ON i.id = p.intent_id +LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE p.payment_identifier = $1 ` @@ -336,7 +336,6 @@ func (q *Queries) FetchPayment(ctx context.Context, paymentIdentifier []byte) (F var i FetchPaymentRow err := row.Scan( &i.Payment.ID, - &i.Payment.IntentID, &i.Payment.AmountMsat, &i.Payment.CreatedAt, &i.Payment.PaymentIdentifier, @@ -398,11 +397,11 @@ func (q *Queries) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, pa const fetchPaymentsByIDs = `-- name: FetchPaymentsByIDs :many SELECT - p.id, p.intent_id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, + p.id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, i.intent_type AS "intent_type", i.intent_payload AS "intent_payload" FROM payments p -LEFT JOIN payment_intents i ON i.id = p.intent_id +LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE p.id IN (/*SLICE:payment_ids*/?) ` @@ -433,7 +432,6 @@ func (q *Queries) FetchPaymentsByIDs(ctx context.Context, paymentIds []int64) ([ var i FetchPaymentsByIDsRow if err := rows.Scan( &i.Payment.ID, - &i.Payment.IntentID, &i.Payment.AmountMsat, &i.Payment.CreatedAt, &i.Payment.PaymentIdentifier, @@ -510,11 +508,11 @@ const filterPayments = `-- name: FilterPayments :many */ SELECT - p.id, p.intent_id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, + p.id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, i.intent_type AS "intent_type", i.intent_payload AS "intent_payload" FROM payments p -LEFT JOIN payment_intents i ON i.id = p.intent_id +LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE ( p.id > $1 OR $1 IS NULL @@ -572,7 +570,6 @@ func (q *Queries) FilterPayments(ctx context.Context, arg FilterPaymentsParams) var i FilterPaymentsRow if err := rows.Scan( &i.Payment.ID, - &i.Payment.IntentID, &i.Payment.AmountMsat, &i.Payment.CreatedAt, &i.Payment.PaymentIdentifier, diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index ce43a3e2976..a94ba1f1f07 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -9,7 +9,7 @@ SELECT i.intent_type AS "intent_type", i.intent_payload AS "intent_payload" FROM payments p -LEFT JOIN payment_intents i ON i.id = p.intent_id +LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE ( p.id > sqlc.narg('index_offset_get') OR sqlc.narg('index_offset_get') IS NULL @@ -37,7 +37,7 @@ SELECT i.intent_type AS "intent_type", i.intent_payload AS "intent_payload" FROM payments p -LEFT JOIN payment_intents i ON i.id = p.intent_id +LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE p.payment_identifier = $1; -- name: FetchPaymentsByIDs :many @@ -46,7 +46,7 @@ SELECT i.intent_type AS "intent_type", i.intent_payload AS "intent_payload" FROM payments p -LEFT JOIN payment_intents i ON i.id = p.intent_id +LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE p.id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/); -- name: CountPayments :one From bdea68bebfdf8ba64a2b773004955de8423f4e7d Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 16:46:55 +0200 Subject: [PATCH 13/78] sqldb: add queries for deleting a payment and attempts --- payments/db/sql_store.go | 10 ++++++++++ sqldb/sqlc/payments.sql.go | 20 ++++++++++++++++++++ sqldb/sqlc/querier.go | 2 ++ sqldb/sqlc/queries/payments.sql | 10 ++++++++++ 4 files changed, 42 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 72fac39d5bf..4e494c80248 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -55,6 +55,16 @@ type SQLQueries interface { FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIDs []int64) ([]sqlc.PaymentFirstHopCustomRecord, error) FetchRouteLevelFirstHopCustomRecords(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.PaymentAttemptFirstHopCustomRecord, error) FetchHopLevelCustomRecords(ctx context.Context, hopIDs []int64) ([]sqlc.PaymentHopCustomRecord, error) + + /* + Payment DB write operations. + */ + + DeletePayment(ctx context.Context, paymentID int64) error + + // DeleteFailedAttempts removes all failed HTLCs from the db for a + // given payment. + DeleteFailedAttempts(ctx context.Context, paymentID int64) error } // BatchedSQLQueries is a version of the SQLQueries that's capable diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index e28e8a52431..ae92aa14625 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -23,6 +23,26 @@ func (q *Queries) CountPayments(ctx context.Context) (int64, error) { return count, err } +const deleteFailedAttempts = `-- name: DeleteFailedAttempts :exec +DELETE FROM payment_htlc_attempts WHERE payment_id = $1 AND attempt_index IN ( + SELECT attempt_index FROM payment_htlc_attempt_resolutions WHERE resolution_type = 2 +) +` + +func (q *Queries) DeleteFailedAttempts(ctx context.Context, paymentID int64) error { + _, err := q.db.ExecContext(ctx, deleteFailedAttempts, paymentID) + return err +} + +const deletePayment = `-- name: DeletePayment :exec +DELETE FROM payments WHERE id = $1 +` + +func (q *Queries) DeletePayment(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deletePayment, id) + return err +} + const fetchAllInflightAttempts = `-- name: FetchAllInflightAttempts :many SELECT ha.id, diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index f4c7673076c..d1605a0964c 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -22,11 +22,13 @@ type Querier interface { DeleteChannelPolicyExtraTypes(ctx context.Context, channelPolicyID int64) error DeleteChannels(ctx context.Context, ids []int64) error DeleteExtraNodeType(ctx context.Context, arg DeleteExtraNodeTypeParams) error + DeleteFailedAttempts(ctx context.Context, paymentID int64) error DeleteInvoice(ctx context.Context, arg DeleteInvoiceParams) (sql.Result, error) DeleteNode(ctx context.Context, id int64) error DeleteNodeAddresses(ctx context.Context, nodeID int64) error DeleteNodeByPubKey(ctx context.Context, arg DeleteNodeByPubKeyParams) (sql.Result, error) DeleteNodeFeature(ctx context.Context, arg DeleteNodeFeatureParams) error + DeletePayment(ctx context.Context, id int64) error DeletePruneLogEntriesInRange(ctx context.Context, arg DeletePruneLogEntriesInRangeParams) error DeleteUnconnectedNodes(ctx context.Context) ([][]byte, error) DeleteZombieChannel(ctx context.Context, arg DeleteZombieChannelParams) (sql.Result, error) diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index a94ba1f1f07..a70631adb01 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -151,3 +151,13 @@ FROM payment_hop_custom_records l WHERE l.hop_id IN (sqlc.slice('hop_ids')/*SLICE:hop_ids*/) ORDER BY l.hop_id ASC, l.key ASC; + +-- name: DeletePayment :exec +DELETE FROM payments WHERE id = $1; + +-- name: DeleteFailedAttempts :exec +-- Delete all failed HTLC attempts for the given payment. Resolution type 2 +-- indicates a failed attempt. +DELETE FROM payment_htlc_attempts WHERE payment_id = $1 AND attempt_index IN ( + SELECT attempt_index FROM payment_htlc_attempt_resolutions WHERE resolution_type = 2 +); From 6faf68c5fae1cdb9fae61ae4549699ec0eff3a09 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 13 Nov 2025 10:53:10 +0100 Subject: [PATCH 14/78] paymentsdb: add query to only fetch resolution type for HTLCs --- payments/db/sql_store.go | 1 + sqldb/sqlc/payments.sql.go | 33 +++++++++++++++++++++++++++++++++ sqldb/sqlc/querier.go | 2 ++ sqldb/sqlc/queries/payments.sql | 9 +++++++++ 4 files changed, 45 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 4e494c80248..d2500d62a5d 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -49,6 +49,7 @@ type SQLQueries interface { CountPayments(ctx context.Context) (int64, error) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptsForPaymentsRow, error) + FetchHtlcAttemptResolutionsForPayment(ctx context.Context, paymentID int64) ([]sql.NullInt32, error) FetchAllInflightAttempts(ctx context.Context) ([]sqlc.PaymentHtlcAttempt, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.FetchHopsForAttemptsRow, error) diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index ae92aa14625..dd135e3db35 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -242,6 +242,39 @@ func (q *Queries) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices [ return items, nil } +const fetchHtlcAttemptResolutionsForPayment = `-- name: FetchHtlcAttemptResolutionsForPayment :many +SELECT + hr.resolution_type +FROM payment_htlc_attempts ha +LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index +WHERE ha.payment_id = $1 +ORDER BY ha.attempt_time ASC +` + +// Lightweight query to fetch only HTLC resolution status. +func (q *Queries) FetchHtlcAttemptResolutionsForPayment(ctx context.Context, paymentID int64) ([]sql.NullInt32, error) { + rows, err := q.db.QueryContext(ctx, fetchHtlcAttemptResolutionsForPayment, paymentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullInt32 + for rows.Next() { + var resolution_type sql.NullInt32 + if err := rows.Scan(&resolution_type); err != nil { + return nil, err + } + items = append(items, resolution_type) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const fetchHtlcAttemptsForPayments = `-- name: FetchHtlcAttemptsForPayments :many SELECT ha.id, diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index d1605a0964c..008624af4bc 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -38,6 +38,8 @@ type Querier interface { FetchAllInflightAttempts(ctx context.Context) ([]PaymentHtlcAttempt, error) FetchHopLevelCustomRecords(ctx context.Context, hopIds []int64) ([]PaymentHopCustomRecord, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]FetchHopsForAttemptsRow, error) + // Lightweight query to fetch only HTLC resolution status. + FetchHtlcAttemptResolutionsForPayment(ctx context.Context, paymentID int64) ([]sql.NullInt32, error) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptsForPaymentsRow, error) FetchPayment(ctx context.Context, paymentIdentifier []byte) (FetchPaymentRow, error) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIds []int64) ([]PaymentFirstHopCustomRecord, error) diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index a70631adb01..b35919c0e6e 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -75,6 +75,15 @@ LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_i WHERE ha.payment_id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/) ORDER BY ha.payment_id ASC, ha.attempt_time ASC; +-- name: FetchHtlcAttemptResolutionsForPayment :many +-- Lightweight query to fetch only HTLC resolution status. +SELECT + hr.resolution_type +FROM payment_htlc_attempts ha +LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index +WHERE ha.payment_id = $1 +ORDER BY ha.attempt_time ASC; + -- name: FetchAllInflightAttempts :many -- Fetch all inflight attempts across all payments SELECT From 3012eb2ca096a596f9b91935a30ae7382b970360 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 16:49:15 +0200 Subject: [PATCH 15/78] paymentsdb: implement DeleteFailedAttempts for sql backend --- payments/db/kv_store.go | 2 + payments/db/sql_store.go | 120 +++++++++++++++++++++++++++++++++++++ sqldb/sqlc/payments.sql.go | 2 + sqldb/sqlc/querier.go | 2 + 4 files changed, 126 insertions(+) diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 62f0b83867e..84946841b9b 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -291,6 +291,8 @@ func (p *KVStore) InitPayment(paymentHash lntypes.Hash, // DeleteFailedAttempts deletes all failed htlcs for a payment if configured // by the KVStore db. func (p *KVStore) DeleteFailedAttempts(hash lntypes.Hash) error { + // TODO(ziggie): Refactor to not mix application logic with database + // logic. This decision should be made in the application layer. if !p.keepFailedPaymentAttempts { const failedHtlcsOnly = true err := p.DeletePayment(hash, failedHtlcsOnly) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index d2500d62a5d..5f22f47a50d 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -712,3 +712,123 @@ func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { return mpPayment, nil } + +// DeleteFailedAttempts removes all failed HTLC attempts from the database for +// the specified payment, while preserving the payment record itself and any +// successful or in-flight attempts. +// +// The method performs the following validations before deletion: +// - StatusInitiated: Can delete failed attempts +// - StatusInFlight: Cannot delete, returns ErrPaymentInFlight (active HTLCs +// still on the network) +// - StatusSucceeded: Can delete failed attempts (payment completed) +// - StatusFailed: Can delete failed attempts (payment permanently failed) +// +// If the keepFailedPaymentAttempts configuration flag is enabled, this method +// returns immediately without deleting anything, allowing failed attempts to +// be retained for debugging or auditing purposes. +// +// This method is idempotent - calling it multiple times on the same payment +// has no adverse effects. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface. It represents +// the final step (step 5) in the payment lifecycle control flow and should be +// called after a payment reaches a terminal state (succeeded or permanently +// failed) to clean up historical failed attempts. +func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { + ctx := context.TODO() + + // In case we are configured to keep failed payment attempts, we exit + // early. + // + // TODO(ziggie): Refactor to not mix application logic with database + // logic. This decision should be made in the application layer. + if s.keepFailedPaymentAttempts { + return nil + } + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch payment: %w", err) + } + + paymentStatus, err := computePaymentStatusFromDB( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + if err := paymentStatus.removable(); err != nil { + return fmt.Errorf("cannot delete failed "+ + "attempts for payment %v: %w", paymentHash, err) + } + + // Then we delete the failed attempts for this payment. + return db.DeleteFailedAttempts(ctx, dbPayment.Payment.ID) + }, sqldb.NoOpReset) + if err != nil { + return fmt.Errorf("failed to delete failed attempts for "+ + "payment %v: %w", paymentHash, err) + } + + return nil +} + +// computePaymentStatusFromDB computes the payment status by fetching minimal +// data from the database. This is a lightweight query optimized for SQL that +// doesn't load route data, making it significantly more efficient than +// FetchPayment when only the status is needed. +func computePaymentStatusFromDB(ctx context.Context, db SQLQueries, + dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { + + payment := dbPayment.GetPayment() + + resolutionTypes, err := db.FetchHtlcAttemptResolutionsForPayment( + ctx, payment.ID, + ) + if err != nil { + return 0, fmt.Errorf("failed to fetch htlc resolutions: %w", + err) + } + + // Build minimal HTLCAttempt slice with only resolution info. + htlcs := make([]HTLCAttempt, len(resolutionTypes)) + for i, resType := range resolutionTypes { + if !resType.Valid { + // NULL resolution_type means in-flight (no Settle, no + // Failure). + continue + } + + switch HTLCAttemptResolutionType(resType.Int32) { + case HTLCAttemptResolutionSettled: + // Mark as settled (preimage details not needed for + // status). + htlcs[i].Settle = &HTLCSettleInfo{} + + case HTLCAttemptResolutionFailed: + // Mark as failed (failure details not needed for + // status). + htlcs[i].Failure = &HTLCFailInfo{} + } + } + + // Convert fail reason to FailureReason pointer. + var failureReason *FailureReason + if payment.FailReason.Valid { + reason := FailureReason(payment.FailReason.Int32) + failureReason = &reason + } + + // Use the existing status decision logic. + status, err := decidePaymentStatus(htlcs, failureReason) + if err != nil { + return 0, fmt.Errorf("failed to decide payment status: %w", err) + } + + return status, nil +} diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index dd135e3db35..0883023a96f 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -29,6 +29,8 @@ DELETE FROM payment_htlc_attempts WHERE payment_id = $1 AND attempt_index IN ( ) ` +// Delete all failed HTLC attempts for the given payment. Resolution type 2 +// indicates a failed attempt. func (q *Queries) DeleteFailedAttempts(ctx context.Context, paymentID int64) error { _, err := q.db.ExecContext(ctx, deleteFailedAttempts, paymentID) return err diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 008624af4bc..9ba6f66a74d 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -22,6 +22,8 @@ type Querier interface { DeleteChannelPolicyExtraTypes(ctx context.Context, channelPolicyID int64) error DeleteChannels(ctx context.Context, ids []int64) error DeleteExtraNodeType(ctx context.Context, arg DeleteExtraNodeTypeParams) error + // Delete all failed HTLC attempts for the given payment. Resolution type 2 + // indicates a failed attempt. DeleteFailedAttempts(ctx context.Context, paymentID int64) error DeleteInvoice(ctx context.Context, arg DeleteInvoiceParams) (sql.Result, error) DeleteNode(ctx context.Context, id int64) error From c3e25ff8efcbe2ed381b55d62dd7389ebd0d6aca Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 16:48:34 +0200 Subject: [PATCH 16/78] paymentsdb: implement DeletePayment for sql backend --- payments/db/sql_store.go | 72 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 5f22f47a50d..e415f63cbcb 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -37,7 +37,7 @@ const ( // SQLQueries is a subset of the sqlc.Querier interface that can be used to // execute queries against the SQL payments tables. // -//nolint:ll +//nolint:ll,interfacebloat type SQLQueries interface { /* Payment DB read operations. @@ -832,3 +832,73 @@ func computePaymentStatusFromDB(ctx context.Context, db SQLQueries, return status, nil } + +// DeletePayment removes a payment or its failed HTLC attempts from the +// database based on the failedAttemptsOnly flag. +// +// If failedAttemptsOnly is true, this method deletes only the failed HTLC +// attempts for the payment while preserving the payment record itself and any +// successful or in-flight attempts. This is useful for cleaning up historical +// failed attempts after a payment reaches a terminal state. +// +// If failedAttemptsOnly is false, this method deletes the entire payment +// record including all payment metadata, payment creation info, all HTLC +// attempts (both failed and successful), and associated data such as payment +// intents and custom records. +// +// Before deletion, this method validates the payment status to ensure it's +// safe to delete: +// - StatusInitiated: Can be deleted (no HTLCs sent yet) +// - StatusInFlight: Cannot be deleted, returns ErrPaymentInFlight (active +// HTLCs on the network) +// - StatusSucceeded: Can be deleted (payment completed successfully) +// - StatusFailed: Can be deleted (payment has failed permanently) +// +// Returns an error if the payment has in-flight HTLCs or if the payment +// doesn't exist. +// +// This method is part of the PaymentWriter interface, which is embedded in +// the DB interface. +func (s *SQLStore) DeletePayment(paymentHash lntypes.Hash, + failedHtlcsOnly bool) error { + + ctx := context.TODO() + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch "+ + "payment: %w", err) + } + + paymentStatus, err := computePaymentStatusFromDB( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + if err := paymentStatus.removable(); err != nil { + return fmt.Errorf("payment %v cannot be deleted: %w", + paymentHash, err) + } + + // If we are only deleting failed HTLCs, we delete them. + if failedHtlcsOnly { + return db.DeleteFailedAttempts( + ctx, dbPayment.Payment.ID, + ) + } + + // In case we are not deleting failed HTLCs, we delete the + // payment which will cascade delete all related data. + return db.DeletePayment(ctx, dbPayment.Payment.ID) + }, sqldb.NoOpReset) + if err != nil { + return fmt.Errorf("failed to delete failed attempts for "+ + "payment %v: %w", paymentHash, err) + } + + return nil +} From 0c96a2d7228fc2c0a55bb07725688e7c4012ac0e Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 17:02:21 +0200 Subject: [PATCH 17/78] sqldb+paymentsdb: add queries to insert all relavant data In this commit we add all queries which we will need to insert payment related data into the db. --- payments/db/sql_store.go | 14 ++ sqldb/sqlc/payments.sql.go | 388 ++++++++++++++++++++++++++++++++ sqldb/sqlc/querier.go | 14 ++ sqldb/sqlc/queries/payments.sql | 182 +++++++++++++++ 4 files changed, 598 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index e415f63cbcb..90aada78645 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -60,6 +60,20 @@ type SQLQueries interface { /* Payment DB write operations. */ + InsertPaymentIntent(ctx context.Context, arg sqlc.InsertPaymentIntentParams) (int64, error) + InsertPayment(ctx context.Context, arg sqlc.InsertPaymentParams) error + InsertPaymentFirstHopCustomRecord(ctx context.Context, arg sqlc.InsertPaymentFirstHopCustomRecordParams) error + + InsertHtlcAttempt(ctx context.Context, arg sqlc.InsertHtlcAttemptParams) (int64, error) + InsertRouteHop(ctx context.Context, arg sqlc.InsertRouteHopParams) (int64, error) + InsertRouteHopMpp(ctx context.Context, arg sqlc.InsertRouteHopMppParams) error + InsertRouteHopAmp(ctx context.Context, arg sqlc.InsertRouteHopAmpParams) error + InsertRouteHopBlinded(ctx context.Context, arg sqlc.InsertRouteHopBlindedParams) error + + InsertPaymentAttemptFirstHopCustomRecord(ctx context.Context, arg sqlc.InsertPaymentAttemptFirstHopCustomRecordParams) error + InsertPaymentHopCustomRecord(ctx context.Context, arg sqlc.InsertPaymentHopCustomRecordParams) error + + SettleAttempt(ctx context.Context, arg sqlc.SettleAttemptParams) error DeletePayment(ctx context.Context, paymentID int64) error diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index 0883023a96f..3b6f6e20d1a 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -45,6 +45,46 @@ func (q *Queries) DeletePayment(ctx context.Context, id int64) error { return err } +const failAttempt = `-- name: FailAttempt :exec +INSERT INTO payment_htlc_attempt_resolutions ( + attempt_index, + resolution_time, + resolution_type, + failure_source_index, + htlc_fail_reason, + failure_msg +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) +` + +type FailAttemptParams struct { + AttemptIndex int64 + ResolutionTime time.Time + ResolutionType int32 + FailureSourceIndex sql.NullInt32 + HtlcFailReason sql.NullInt32 + FailureMsg []byte +} + +func (q *Queries) FailAttempt(ctx context.Context, arg FailAttemptParams) error { + _, err := q.db.ExecContext(ctx, failAttempt, + arg.AttemptIndex, + arg.ResolutionTime, + arg.ResolutionType, + arg.FailureSourceIndex, + arg.HtlcFailReason, + arg.FailureMsg, + ) + return err +} + const fetchAllInflightAttempts = `-- name: FetchAllInflightAttempts :many SELECT ha.id, @@ -644,3 +684,351 @@ func (q *Queries) FilterPayments(ctx context.Context, arg FilterPaymentsParams) } return items, nil } + +const insertHtlcAttempt = `-- name: InsertHtlcAttempt :one +INSERT INTO payment_htlc_attempts ( + payment_id, + attempt_index, + session_key, + attempt_time, + payment_hash, + first_hop_amount_msat, + route_total_time_lock, + route_total_amount, + route_source_key) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9) +RETURNING id +` + +type InsertHtlcAttemptParams struct { + PaymentID int64 + AttemptIndex int64 + SessionKey []byte + AttemptTime time.Time + PaymentHash []byte + FirstHopAmountMsat int64 + RouteTotalTimeLock int32 + RouteTotalAmount int64 + RouteSourceKey []byte +} + +func (q *Queries) InsertHtlcAttempt(ctx context.Context, arg InsertHtlcAttemptParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertHtlcAttempt, + arg.PaymentID, + arg.AttemptIndex, + arg.SessionKey, + arg.AttemptTime, + arg.PaymentHash, + arg.FirstHopAmountMsat, + arg.RouteTotalTimeLock, + arg.RouteTotalAmount, + arg.RouteSourceKey, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const insertPayment = `-- name: InsertPayment :one +INSERT INTO payments ( + amount_msat, + created_at, + payment_identifier, + fail_reason) +VALUES ( + $1, + $2, + $3, + NULL +) +RETURNING id +` + +type InsertPaymentParams struct { + AmountMsat int64 + CreatedAt time.Time + PaymentIdentifier []byte +} + +// Insert a new payment and return its ID. +func (q *Queries) InsertPayment(ctx context.Context, arg InsertPaymentParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertPayment, arg.AmountMsat, arg.CreatedAt, arg.PaymentIdentifier) + var id int64 + err := row.Scan(&id) + return id, err +} + +const insertPaymentAttemptFirstHopCustomRecord = `-- name: InsertPaymentAttemptFirstHopCustomRecord :exec +INSERT INTO payment_attempt_first_hop_custom_records ( + htlc_attempt_index, + key, + value +) +VALUES ( + $1, + $2, + $3 +) +` + +type InsertPaymentAttemptFirstHopCustomRecordParams struct { + HtlcAttemptIndex int64 + Key int64 + Value []byte +} + +func (q *Queries) InsertPaymentAttemptFirstHopCustomRecord(ctx context.Context, arg InsertPaymentAttemptFirstHopCustomRecordParams) error { + _, err := q.db.ExecContext(ctx, insertPaymentAttemptFirstHopCustomRecord, arg.HtlcAttemptIndex, arg.Key, arg.Value) + return err +} + +const insertPaymentFirstHopCustomRecord = `-- name: InsertPaymentFirstHopCustomRecord :exec +INSERT INTO payment_first_hop_custom_records ( + payment_id, + key, + value +) +VALUES ( + $1, + $2, + $3 +) +` + +type InsertPaymentFirstHopCustomRecordParams struct { + PaymentID int64 + Key int64 + Value []byte +} + +func (q *Queries) InsertPaymentFirstHopCustomRecord(ctx context.Context, arg InsertPaymentFirstHopCustomRecordParams) error { + _, err := q.db.ExecContext(ctx, insertPaymentFirstHopCustomRecord, arg.PaymentID, arg.Key, arg.Value) + return err +} + +const insertPaymentHopCustomRecord = `-- name: InsertPaymentHopCustomRecord :exec +INSERT INTO payment_hop_custom_records ( + hop_id, + key, + value +) +VALUES ( + $1, + $2, + $3 +) +` + +type InsertPaymentHopCustomRecordParams struct { + HopID int64 + Key int64 + Value []byte +} + +func (q *Queries) InsertPaymentHopCustomRecord(ctx context.Context, arg InsertPaymentHopCustomRecordParams) error { + _, err := q.db.ExecContext(ctx, insertPaymentHopCustomRecord, arg.HopID, arg.Key, arg.Value) + return err +} + +const insertPaymentIntent = `-- name: InsertPaymentIntent :one +INSERT INTO payment_intents ( + payment_id, + intent_type, + intent_payload) +VALUES ( + $1, + $2, + $3 +) +RETURNING id +` + +type InsertPaymentIntentParams struct { + PaymentID int64 + IntentType int16 + IntentPayload []byte +} + +// Insert a payment intent for a given payment and return its ID. +func (q *Queries) InsertPaymentIntent(ctx context.Context, arg InsertPaymentIntentParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertPaymentIntent, arg.PaymentID, arg.IntentType, arg.IntentPayload) + var id int64 + err := row.Scan(&id) + return id, err +} + +const insertRouteHop = `-- name: InsertRouteHop :one +INSERT INTO payment_route_hops ( + htlc_attempt_index, + hop_index, + pub_key, + scid, + outgoing_time_lock, + amt_to_forward, + meta_data +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +RETURNING id +` + +type InsertRouteHopParams struct { + HtlcAttemptIndex int64 + HopIndex int32 + PubKey []byte + Scid string + OutgoingTimeLock int32 + AmtToForward int64 + MetaData []byte +} + +func (q *Queries) InsertRouteHop(ctx context.Context, arg InsertRouteHopParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertRouteHop, + arg.HtlcAttemptIndex, + arg.HopIndex, + arg.PubKey, + arg.Scid, + arg.OutgoingTimeLock, + arg.AmtToForward, + arg.MetaData, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const insertRouteHopAmp = `-- name: InsertRouteHopAmp :exec +INSERT INTO payment_route_hop_amp ( + hop_id, + root_share, + set_id, + child_index +) +VALUES ( + $1, + $2, + $3, + $4 +) +` + +type InsertRouteHopAmpParams struct { + HopID int64 + RootShare []byte + SetID []byte + ChildIndex int32 +} + +func (q *Queries) InsertRouteHopAmp(ctx context.Context, arg InsertRouteHopAmpParams) error { + _, err := q.db.ExecContext(ctx, insertRouteHopAmp, + arg.HopID, + arg.RootShare, + arg.SetID, + arg.ChildIndex, + ) + return err +} + +const insertRouteHopBlinded = `-- name: InsertRouteHopBlinded :exec +INSERT INTO payment_route_hop_blinded ( + hop_id, + encrypted_data, + blinding_point, + blinded_path_total_amt +) +VALUES ( + $1, + $2, + $3, + $4 +) +` + +type InsertRouteHopBlindedParams struct { + HopID int64 + EncryptedData []byte + BlindingPoint []byte + BlindedPathTotalAmt sql.NullInt64 +} + +func (q *Queries) InsertRouteHopBlinded(ctx context.Context, arg InsertRouteHopBlindedParams) error { + _, err := q.db.ExecContext(ctx, insertRouteHopBlinded, + arg.HopID, + arg.EncryptedData, + arg.BlindingPoint, + arg.BlindedPathTotalAmt, + ) + return err +} + +const insertRouteHopMpp = `-- name: InsertRouteHopMpp :exec +INSERT INTO payment_route_hop_mpp ( + hop_id, + payment_addr, + total_msat +) +VALUES ( + $1, + $2, + $3 +) +` + +type InsertRouteHopMppParams struct { + HopID int64 + PaymentAddr []byte + TotalMsat int64 +} + +func (q *Queries) InsertRouteHopMpp(ctx context.Context, arg InsertRouteHopMppParams) error { + _, err := q.db.ExecContext(ctx, insertRouteHopMpp, arg.HopID, arg.PaymentAddr, arg.TotalMsat) + return err +} + +const settleAttempt = `-- name: SettleAttempt :exec +INSERT INTO payment_htlc_attempt_resolutions ( + attempt_index, + resolution_time, + resolution_type, + settle_preimage +) +VALUES ( + $1, + $2, + $3, + $4 +) +` + +type SettleAttemptParams struct { + AttemptIndex int64 + ResolutionTime time.Time + ResolutionType int32 + SettlePreimage []byte +} + +func (q *Queries) SettleAttempt(ctx context.Context, arg SettleAttemptParams) error { + _, err := q.db.ExecContext(ctx, settleAttempt, + arg.AttemptIndex, + arg.ResolutionTime, + arg.ResolutionType, + arg.SettlePreimage, + ) + return err +} diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 9ba6f66a74d..9810825d90a 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -34,6 +34,7 @@ type Querier interface { DeletePruneLogEntriesInRange(ctx context.Context, arg DeletePruneLogEntriesInRangeParams) error DeleteUnconnectedNodes(ctx context.Context) ([][]byte, error) DeleteZombieChannel(ctx context.Context, arg DeleteZombieChannelParams) (sql.Result, error) + FailAttempt(ctx context.Context, arg FailAttemptParams) error FetchAMPSubInvoiceHTLCs(ctx context.Context, arg FetchAMPSubInvoiceHTLCsParams) ([]FetchAMPSubInvoiceHTLCsRow, error) FetchAMPSubInvoices(ctx context.Context, arg FetchAMPSubInvoicesParams) ([]AmpSubInvoice, error) // Fetch all inflight attempts across all payments @@ -147,6 +148,7 @@ type Querier interface { // UpsertEdgePolicy query is used because of the constraint in that query that // requires a policy update to have a newer last_update than the existing one). InsertEdgePolicyMig(ctx context.Context, arg InsertEdgePolicyMigParams) (int64, error) + InsertHtlcAttempt(ctx context.Context, arg InsertHtlcAttemptParams) (int64, error) InsertInvoice(ctx context.Context, arg InsertInvoiceParams) (int64, error) InsertInvoiceFeature(ctx context.Context, arg InsertInvoiceFeatureParams) error InsertInvoiceHTLC(ctx context.Context, arg InsertInvoiceHTLCParams) (int64, error) @@ -160,6 +162,17 @@ 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) + // Insert a new payment and return its ID. + InsertPayment(ctx context.Context, arg InsertPaymentParams) (int64, error) + InsertPaymentAttemptFirstHopCustomRecord(ctx context.Context, arg InsertPaymentAttemptFirstHopCustomRecordParams) error + InsertPaymentFirstHopCustomRecord(ctx context.Context, arg InsertPaymentFirstHopCustomRecordParams) error + InsertPaymentHopCustomRecord(ctx context.Context, arg InsertPaymentHopCustomRecordParams) error + // Insert a payment intent for a given payment and return its ID. + InsertPaymentIntent(ctx context.Context, arg InsertPaymentIntentParams) (int64, error) + InsertRouteHop(ctx context.Context, arg InsertRouteHopParams) (int64, error) + InsertRouteHopAmp(ctx context.Context, arg InsertRouteHopAmpParams) error + InsertRouteHopBlinded(ctx context.Context, arg InsertRouteHopBlindedParams) error + InsertRouteHopMpp(ctx context.Context, arg InsertRouteHopMppParams) error IsClosedChannel(ctx context.Context, scid []byte) (bool, error) IsPublicV1Node(ctx context.Context, pubKey []byte) (bool, error) IsPublicV2Node(ctx context.Context, pubKey []byte) (bool, error) @@ -181,6 +194,7 @@ type Querier interface { OnInvoiceSettled(ctx context.Context, arg OnInvoiceSettledParams) error SetKVInvoicePaymentHash(ctx context.Context, arg SetKVInvoicePaymentHashParams) error SetMigration(ctx context.Context, arg SetMigrationParams) error + SettleAttempt(ctx context.Context, arg SettleAttemptParams) error UpdateAMPSubInvoiceHTLCPreimage(ctx context.Context, arg UpdateAMPSubInvoiceHTLCPreimageParams) (sql.Result, error) UpdateAMPSubInvoiceState(ctx context.Context, arg UpdateAMPSubInvoiceStateParams) error UpdateInvoiceAmountPaid(ctx context.Context, arg UpdateInvoiceAmountPaidParams) (sql.Result, error) diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index b35919c0e6e..b0183ccd2a6 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -170,3 +170,185 @@ DELETE FROM payments WHERE id = $1; DELETE FROM payment_htlc_attempts WHERE payment_id = $1 AND attempt_index IN ( SELECT attempt_index FROM payment_htlc_attempt_resolutions WHERE resolution_type = 2 ); + +-- name: InsertPaymentIntent :one +-- Insert a payment intent for a given payment and return its ID. +INSERT INTO payment_intents ( + payment_id, + intent_type, + intent_payload) +VALUES ( + @payment_id, + @intent_type, + @intent_payload +) +RETURNING id; + +-- name: InsertPayment :one +-- 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. +INSERT INTO payments ( + amount_msat, + created_at, + payment_identifier, + fail_reason) +VALUES ( + @amount_msat, + @created_at, + @payment_identifier, + NULL +) +RETURNING id; + +-- name: InsertPaymentFirstHopCustomRecord :exec +INSERT INTO payment_first_hop_custom_records ( + payment_id, + key, + value +) +VALUES ( + @payment_id, + @key, + @value +); + +-- name: InsertHtlcAttempt :one +INSERT INTO payment_htlc_attempts ( + payment_id, + attempt_index, + session_key, + attempt_time, + payment_hash, + first_hop_amount_msat, + route_total_time_lock, + route_total_amount, + route_source_key) +VALUES ( + @payment_id, + @attempt_index, + @session_key, + @attempt_time, + @payment_hash, + @first_hop_amount_msat, + @route_total_time_lock, + @route_total_amount, + @route_source_key) +RETURNING id; + +-- name: InsertPaymentAttemptFirstHopCustomRecord :exec +INSERT INTO payment_attempt_first_hop_custom_records ( + htlc_attempt_index, + key, + value +) +VALUES ( + @htlc_attempt_index, + @key, + @value +); + +-- name: InsertRouteHop :one +INSERT INTO payment_route_hops ( + htlc_attempt_index, + hop_index, + pub_key, + scid, + outgoing_time_lock, + amt_to_forward, + meta_data +) +VALUES ( + @htlc_attempt_index, + @hop_index, + @pub_key, + @scid, + @outgoing_time_lock, + @amt_to_forward, + @meta_data +) +RETURNING id; + +-- name: InsertRouteHopMpp :exec +INSERT INTO payment_route_hop_mpp ( + hop_id, + payment_addr, + total_msat +) +VALUES ( + @hop_id, + @payment_addr, + @total_msat +); + +-- name: InsertRouteHopAmp :exec +INSERT INTO payment_route_hop_amp ( + hop_id, + root_share, + set_id, + child_index +) +VALUES ( + @hop_id, + @root_share, + @set_id, + @child_index +); + +-- name: InsertRouteHopBlinded :exec +INSERT INTO payment_route_hop_blinded ( + hop_id, + encrypted_data, + blinding_point, + blinded_path_total_amt +) +VALUES ( + @hop_id, + @encrypted_data, + @blinding_point, + @blinded_path_total_amt +); + +-- name: InsertPaymentHopCustomRecord :exec +INSERT INTO payment_hop_custom_records ( + hop_id, + key, + value +) +VALUES ( + @hop_id, + @key, + @value +); + +-- name: SettleAttempt :exec +INSERT INTO payment_htlc_attempt_resolutions ( + attempt_index, + resolution_time, + resolution_type, + settle_preimage +) +VALUES ( + @attempt_index, + @resolution_time, + @resolution_type, + @settle_preimage +); + +-- name: FailAttempt :exec +INSERT INTO payment_htlc_attempt_resolutions ( + attempt_index, + resolution_time, + resolution_type, + failure_source_index, + htlc_fail_reason, + failure_msg +) +VALUES ( + @attempt_index, + @resolution_time, + @resolution_type, + @failure_source_index, + @htlc_fail_reason, + @failure_msg +); From 510f6fb532bfdaca4be4084583252eb77e4c2b8b Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 17:06:05 +0200 Subject: [PATCH 18/78] paymentsdb: implement InitPayment for sql backend --- payments/db/sql_store.go | 130 ++++++++++++++++++++++++++++++++++++- sqldb/sqlc/payments.sql.go | 2 + sqldb/sqlc/querier.go | 2 + 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 90aada78645..4d909022782 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -61,7 +61,7 @@ type SQLQueries interface { Payment DB write operations. */ InsertPaymentIntent(ctx context.Context, arg sqlc.InsertPaymentIntentParams) (int64, error) - InsertPayment(ctx context.Context, arg sqlc.InsertPaymentParams) error + InsertPayment(ctx context.Context, arg sqlc.InsertPaymentParams) (int64, error) InsertPaymentFirstHopCustomRecord(ctx context.Context, arg sqlc.InsertPaymentFirstHopCustomRecordParams) error InsertHtlcAttempt(ctx context.Context, arg sqlc.InsertHtlcAttemptParams) (int64, error) @@ -916,3 +916,131 @@ func (s *SQLStore) DeletePayment(paymentHash lntypes.Hash, return nil } + +// InitPayment creates a new payment record in the database with the given +// payment hash and creation info. +// +// Before creating the payment, this method checks if a payment with the same +// hash already exists and validates whether initialization is allowed based on +// the existing payment's status: +// - StatusInitiated: Returns ErrPaymentExists (payment already created, +// HTLCs may be in flight) +// - StatusInFlight: Returns ErrPaymentInFlight (payment currently being +// attempted) +// - StatusSucceeded: Returns ErrAlreadyPaid (payment already succeeded) +// - StatusFailed: Allows retry by deleting the old payment record and +// creating a new one +// +// If no existing payment is found, a new payment record is created with +// StatusInitiated and stored with all associated metadata. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface, representing +// the first step in the payment lifecycle control flow. +func (s *SQLStore) InitPayment(paymentHash lntypes.Hash, + paymentCreationInfo *PaymentCreationInfo) error { + + ctx := context.TODO() + + // Create the payment in the database. + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + existingPayment, err := db.FetchPayment(ctx, paymentHash[:]) + switch { + // A payment with this hash already exists. We need to check its + // status to see if we can re-initialize. + case err == nil: + paymentStatus, err := computePaymentStatusFromDB( + ctx, db, existingPayment, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + // Check if the payment is initializable otherwise + // we'll return early. + if err := paymentStatus.initializable(); err != nil { + return fmt.Errorf("payment is not "+ + "initializable: %w", err) + } + + // If the initializable check above passes, then the + // existing payment has failed. So we delete it and + // all of its previous artifacts. We rely on + // cascading deletes to clean up the rest. + err = db.DeletePayment(ctx, existingPayment.Payment.ID) + if err != nil { + return fmt.Errorf("failed to delete "+ + "payment: %w", err) + } + + // An unexpected error occurred while fetching the payment. + case !errors.Is(err, sql.ErrNoRows): + // Some other error occurred + return fmt.Errorf("failed to check existing "+ + "payment: %w", err) + + // The payment does not yet exist, so we can proceed. + default: + } + + // Insert the payment first to get its ID. + paymentID, err := db.InsertPayment( + ctx, sqlc.InsertPaymentParams{ + AmountMsat: int64( + paymentCreationInfo.Value, + ), + CreatedAt: paymentCreationInfo. + CreationTime.UTC(), + PaymentIdentifier: paymentHash[:], + }, + ) + if err != nil { + return fmt.Errorf("failed to insert payment: %w", err) + } + + // If there's a payment request, insert the payment intent. + if len(paymentCreationInfo.PaymentRequest) > 0 { + _, err = db.InsertPaymentIntent( + ctx, sqlc.InsertPaymentIntentParams{ + PaymentID: paymentID, + IntentType: int16( + PaymentIntentTypeBolt11, + ), + IntentPayload: paymentCreationInfo. + PaymentRequest, + }, + ) + if err != nil { + return fmt.Errorf("failed to insert "+ + "payment intent: %w", err) + } + } + + firstHopCustomRecords := paymentCreationInfo. + FirstHopCustomRecords + + for key, value := range firstHopCustomRecords { + err = db.InsertPaymentFirstHopCustomRecord( + ctx, + sqlc.InsertPaymentFirstHopCustomRecordParams{ + PaymentID: paymentID, + Key: int64(key), + Value: value, + }, + ) + if err != nil { + return fmt.Errorf("failed to insert "+ + "payment first hop custom "+ + "record: %w", err) + } + } + + return nil + }, sqldb.NoOpReset) + if err != nil { + return fmt.Errorf("failed to initialize payment: %w", err) + } + + return nil +} diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index 3b6f6e20d1a..fb117bcaad0 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -760,6 +760,8 @@ type InsertPaymentParams struct { } // 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. func (q *Queries) InsertPayment(ctx context.Context, arg InsertPaymentParams) (int64, error) { row := q.db.QueryRowContext(ctx, insertPayment, arg.AmountMsat, arg.CreatedAt, arg.PaymentIdentifier) var id int64 diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 9810825d90a..cc52f06f1b9 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -163,6 +163,8 @@ type Querier interface { // to have a newer last_update than the existing node). InsertNodeMig(ctx context.Context, arg InsertNodeMigParams) (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. InsertPayment(ctx context.Context, arg InsertPaymentParams) (int64, error) InsertPaymentAttemptFirstHopCustomRecord(ctx context.Context, arg InsertPaymentAttemptFirstHopCustomRecordParams) error InsertPaymentFirstHopCustomRecord(ctx context.Context, arg InsertPaymentFirstHopCustomRecordParams) error From d2cdd9d86f3cf519a4ad9427e9a6ea8d70a6a75a Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 13 Nov 2025 10:42:05 +0100 Subject: [PATCH 19/78] paymentsdb: add note to RegisterAttempt --- payments/db/interface.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/payments/db/interface.go b/payments/db/interface.go index c41dc371f89..7fefad08917 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -61,6 +61,17 @@ type PaymentControl interface { InitPayment(lntypes.Hash, *PaymentCreationInfo) error // RegisterAttempt atomically records the provided HTLCAttemptInfo. + // + // IMPORTANT: Callers MUST serialize calls to RegisterAttempt for the + // same payment hash. Concurrent calls will result in race conditions + // where both calls read the same initial payment state, validate + // against stale data, and could cause overpayment. For example: + // - Both goroutines fetch payment with 400 sats sent + // - Both validate sending 650 sats won't overpay (within limit) + // - Both commit successfully + // - Result: 1700 sats sent, exceeding the payment amount + // The payment router/controller layer is responsible for ensuring + // serialized access per payment hash. RegisterAttempt(lntypes.Hash, *HTLCAttemptInfo) (*MPPayment, error) // SettleAttempt marks the given attempt settled with the preimage. If From 40c8502fd3254b1d2233bc7180fdbed96d80508e Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 17:08:03 +0200 Subject: [PATCH 20/78] paymentsdb: implement RegisterAttempt for sql backend --- payments/db/sql_store.go | 246 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 4d909022782..83f694366dc 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -6,10 +6,12 @@ import ( "errors" "fmt" "math" + "strconv" "time" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/sqldb" "github.com/lightningnetwork/lnd/sqldb/sqlc" ) @@ -1044,3 +1046,247 @@ func (s *SQLStore) InitPayment(paymentHash lntypes.Hash, return nil } + +// insertRouteHops inserts all route hop data for a given set of hops. +func (s *SQLStore) insertRouteHops(ctx context.Context, db SQLQueries, + hops []*route.Hop, attemptID uint64) error { + + for i, hop := range hops { + // Insert the basic route hop data and get the generated ID. + hopID, err := db.InsertRouteHop(ctx, sqlc.InsertRouteHopParams{ + HtlcAttemptIndex: int64(attemptID), + HopIndex: int32(i), + PubKey: hop.PubKeyBytes[:], + Scid: strconv.FormatUint( + hop.ChannelID, 10, + ), + OutgoingTimeLock: int32(hop.OutgoingTimeLock), + AmtToForward: int64(hop.AmtToForward), + MetaData: hop.Metadata, + }) + if err != nil { + return fmt.Errorf("failed to insert route hop: %w", err) + } + + // Insert the per-hop custom records. + if len(hop.CustomRecords) > 0 { + for key, value := range hop.CustomRecords { + err = db.InsertPaymentHopCustomRecord( + ctx, + sqlc.InsertPaymentHopCustomRecordParams{ + HopID: hopID, + Key: int64(key), + Value: value, + }) + if err != nil { + return fmt.Errorf("failed to insert "+ + "payment hop custom record: %w", + err) + } + } + } + + // Insert MPP data if present. + if hop.MPP != nil { + paymentAddr := hop.MPP.PaymentAddr() + err = db.InsertRouteHopMpp( + ctx, sqlc.InsertRouteHopMppParams{ + HopID: hopID, + PaymentAddr: paymentAddr[:], + TotalMsat: int64(hop.MPP.TotalMsat()), + }) + if err != nil { + return fmt.Errorf("failed to insert "+ + "route hop MPP: %w", err) + } + } + + // Insert AMP data if present. + if hop.AMP != nil { + rootShare := hop.AMP.RootShare() + setID := hop.AMP.SetID() + err = db.InsertRouteHopAmp( + ctx, sqlc.InsertRouteHopAmpParams{ + HopID: hopID, + RootShare: rootShare[:], + SetID: setID[:], + ChildIndex: int32(hop.AMP.ChildIndex()), + }) + if err != nil { + return fmt.Errorf("failed to insert "+ + "route hop AMP: %w", err) + } + } + + // Insert blinded route data if present. Every hop in the + // blinded path must have an encrypted data record. If the + // encrypted data is not present, we skip the insertion. + if hop.EncryptedData == nil { + continue + } + + // The introduction point has a blinding point set. + var blindingPointBytes []byte + if hop.BlindingPoint != nil { + blindingPointBytes = hop.BlindingPoint. + SerializeCompressed() + } + + // The total amount is only set for the final hop in a + // blinded path. + totalAmtMsat := sql.NullInt64{} + if i == len(hops)-1 { + totalAmtMsat = sql.NullInt64{ + Int64: int64(hop.TotalAmtMsat), + Valid: true, + } + } + + err = db.InsertRouteHopBlinded(ctx, + sqlc.InsertRouteHopBlindedParams{ + HopID: hopID, + EncryptedData: hop.EncryptedData, + BlindingPoint: blindingPointBytes, + BlindedPathTotalAmt: totalAmtMsat, + }, + ) + if err != nil { + return fmt.Errorf("failed to insert "+ + "route hop blinded: %w", err) + } + } + + return nil +} + +// RegisterAttempt atomically records a new HTLC attempt for the specified +// payment. The attempt includes the attempt ID, session key, route information +// (hops, timelocks, amounts), and optional data such as MPP/AMP parameters, +// blinded route data, and custom records. +// +// Returns the updated MPPayment with the new attempt appended to the HTLCs +// slice, and the payment state recalculated. Returns an error if the payment +// doesn't exist or validation fails. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface. It represents +// step 2 in the payment lifecycle control flow, called after InitPayment and +// potentially multiple times for multi-path payments. +func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, + attempt *HTLCAttemptInfo) (*MPPayment, error) { + + ctx := context.TODO() + + var mpPayment *MPPayment + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + // First Fetch the payment and check if it is registrable. + existingPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch payment: %w", err) + } + + // We fetch the complete payment to determine if the payment is + // registrable. + // + // TODO(ziggie): We could improve the query here since only + // the last hop data is needed here not the complete payment + // data. + mpPayment, err = s.fetchPaymentWithCompleteData( + ctx, db, existingPayment, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment with "+ + "complete data: %w", err) + } + + if err := mpPayment.Registrable(); err != nil { + return fmt.Errorf("htlc attempt not registrable: %w", + err) + } + + // Verify the attempt is compatible with the existing payment. + if err := verifyAttempt(mpPayment, attempt); err != nil { + return fmt.Errorf("failed to verify attempt: %w", err) + } + + // Register the plain HTLC attempt next. + sessionKey := attempt.SessionKey() + sessionKeyBytes := sessionKey.Serialize() + + _, err = db.InsertHtlcAttempt(ctx, sqlc.InsertHtlcAttemptParams{ + PaymentID: existingPayment.Payment.ID, + AttemptIndex: int64(attempt.AttemptID), + SessionKey: sessionKeyBytes, + AttemptTime: attempt.AttemptTime, + PaymentHash: paymentHash[:], + FirstHopAmountMsat: int64( + attempt.Route.FirstHopAmount.Val.Int(), + ), + RouteTotalTimeLock: int32(attempt.Route.TotalTimeLock), + RouteTotalAmount: int64(attempt.Route.TotalAmount), + RouteSourceKey: attempt.Route.SourcePubKey[:], + }) + if err != nil { + return fmt.Errorf("failed to insert HTLC "+ + "attempt: %w", err) + } + + // Insert the route level first hop custom records. + attemptFirstHopCustomRecords := attempt.Route. + FirstHopWireCustomRecords + + for key, value := range attemptFirstHopCustomRecords { + //nolint:ll + err = db.InsertPaymentAttemptFirstHopCustomRecord( + ctx, + sqlc.InsertPaymentAttemptFirstHopCustomRecordParams{ + HtlcAttemptIndex: int64(attempt.AttemptID), + Key: int64(key), + Value: value, + }, + ) + if err != nil { + return fmt.Errorf("failed to insert "+ + "payment attempt first hop custom "+ + "record: %w", err) + } + } + + // Insert the route hops. + err = s.insertRouteHops( + ctx, db, attempt.Route.Hops, attempt.AttemptID, + ) + if err != nil { + return fmt.Errorf("failed to insert route hops: %w", + err) + } + + // We fetch the HTLC attempts again to recalculate the payment + // state after the attempt is registered. This also makes sure + // we have the right data in case multiple attempts are + // registered concurrently. + // + // NOTE: While the caller is responsible for serializing calls + // to RegisterAttempt per payment hash (see PaymentControl + // interface), we still refetch here to guarantee we return + // consistent, up-to-date data that reflects all changes made + // within this transaction. + mpPayment, err = s.fetchPaymentWithCompleteData( + ctx, db, existingPayment, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment with "+ + "complete data: %w", err) + } + + return nil + }, func() { + mpPayment = nil + }) + if err != nil { + return nil, fmt.Errorf("failed to register attempt: %w", err) + } + + return mpPayment, nil +} From c0e55b5855e9693f34c8878d0de83d28e9e816a6 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 11 Nov 2025 10:54:39 +0100 Subject: [PATCH 21/78] paymentsdb: verify total amount for last hop in the blinded path --- payments/db/errors.go | 6 ++++++ payments/db/payment.go | 7 +++++++ payments/db/payment_test.go | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/payments/db/errors.go b/payments/db/errors.go index fee71b05f59..0457db60035 100644 --- a/payments/db/errors.go +++ b/payments/db/errors.go @@ -84,6 +84,12 @@ var ( ErrMixedBlindedAndNonBlindedPayments = errors.New("mixed blinded and " + "non-blinded payments") + // ErrBlindedPaymentMissingTotalAmount is returned if we try to + // register a blinded payment attempt where the final hop doesn't set + // the total amount. + ErrBlindedPaymentMissingTotalAmount = errors.New("blinded payment " + + "final hop must set total amount") + // ErrMPPPaymentAddrMismatch is returned if we try to register an MPP // shard where the payment address doesn't match existing shards. ErrMPPPaymentAddrMismatch = errors.New("payment address mismatch") diff --git a/payments/db/payment.go b/payments/db/payment.go index 147ccdb1e77..ddceedfb0f0 100644 --- a/payments/db/payment.go +++ b/payments/db/payment.go @@ -744,6 +744,13 @@ func verifyAttempt(payment *MPPayment, attempt *HTLCAttemptInfo) error { // in the split payment is correct. isBlinded := len(attempt.Route.FinalHop().EncryptedData) != 0 + // For blinded payments, the last hop must set the total amount. + if isBlinded { + if attempt.Route.FinalHop().TotalAmtMsat == 0 { + return ErrBlindedPaymentMissingTotalAmount + } + } + // Make sure any existing shards match the new one with regards // to MPP options. mpp := attempt.Route.FinalHop().MPP diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index a7369c14b80..e6a2e735a9c 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -1388,6 +1388,45 @@ func TestVerifyAttemptBlindedValidation(t *testing.T) { require.NoError(t, verifyAttempt(payment, &matching)) } +// TestVerifyAttemptBlindedMissingTotalAmount tests that we return an error if +// we try to register a blinded payment attempt where the final hop doesn't set +// the total amount. +func TestVerifyAttemptBlindedMissingTotalAmount(t *testing.T) { + t.Parallel() + + total := lnwire.MilliSatoshi(5000) + + // Payment with no existing attempts. + payment := makePayment(total) + + // Attempt with encrypted data (blinded payment) but missing total + // amount. + attemptMissingTotal := makeLastHopAttemptInfo( + 1, + lastHopArgs{ + amt: 2500, + total: 0, + encrypted: []byte{1, 2, 3}, + }, + ) + require.ErrorIs( + t, + verifyAttempt(payment, &attemptMissingTotal), + ErrBlindedPaymentMissingTotalAmount, + ) + + // Attempt with encrypted data and valid total amount should succeed. + attemptWithTotal := makeLastHopAttemptInfo( + 2, + lastHopArgs{ + amt: 2500, + total: total, + encrypted: []byte{4, 5, 6}, + }, + ) + require.NoError(t, verifyAttempt(payment, &attemptWithTotal)) +} + // TestVerifyAttemptBlindedMixedWithNonBlinded tests that we return an error if // we try to register a non-MPP attempt for a blinded payment. func TestVerifyAttemptBlindedMixedWithNonBlinded(t *testing.T) { From 620dacc432ae597bbc1511007a508042504f1e10 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 17:09:05 +0200 Subject: [PATCH 22/78] paymentsdb: implement SettleAttempt for sql backend --- payments/db/sql_store.go | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 83f694366dc..8030a8694e5 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1290,3 +1290,66 @@ func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, return mpPayment, nil } + +// SettleAttempt marks the specified HTLC attempt as successfully settled, +// recording the payment preimage and settlement time. The preimage serves as +// cryptographic proof of payment and is atomically saved to the database. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface. It represents +// step 3a in the payment lifecycle control flow (step 3b is FailAttempt), +// called after RegisterAttempt when an HTLC successfully completes. +func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, + attemptID uint64, settleInfo *HTLCSettleInfo) (*MPPayment, error) { + + ctx := context.TODO() + + var mpPayment *MPPayment + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch payment: %w", err) + } + + paymentStatus, err := computePaymentStatusFromDB( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + if err := paymentStatus.updatable(); err != nil { + return fmt.Errorf("payment is not updatable: %w", err) + } + + err = db.SettleAttempt(ctx, sqlc.SettleAttemptParams{ + AttemptIndex: int64(attemptID), + ResolutionTime: time.Now(), + ResolutionType: int32(HTLCAttemptResolutionSettled), + SettlePreimage: settleInfo.Preimage[:], + }) + if err != nil { + return fmt.Errorf("failed to settle attempt: %w", err) + } + + // Fetch the complete payment after we settled the attempt. + mpPayment, err = s.fetchPaymentWithCompleteData( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment with "+ + "complete data: %w", err) + } + + return nil + }, func() { + mpPayment = nil + }) + if err != nil { + return nil, fmt.Errorf("failed to settle attempt: %w", err) + } + + return mpPayment, nil +} From 4c867c1b8861566b563bfdfba145e964d4aa219d Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 17:11:11 +0200 Subject: [PATCH 23/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index e01f35c9379..dfd8c714e22 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -205,6 +205,8 @@ database](https://github.com/lightningnetwork/lnd/pull/9147) * Implement query methods (QueryPayments,FetchPayment) for the [payments db SQL Backend](https://github.com/lightningnetwork/lnd/pull/10287) + * Implement insert methods for the [payments db + SQL Backend](https://github.com/lightningnetwork/lnd/pull/10291) ## Code Health From 7d87c0ce2ff5c49f0ac36aacbbab64cb330726a7 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 21 Nov 2025 00:42:11 +0100 Subject: [PATCH 24/78] paymentsdb: fix formatting for sql QueryPayments --- payments/db/sql_store.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 8030a8694e5..15346efbfb0 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -530,9 +530,7 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, initialCursor int64 ) - extractCursor := func( - row sqlc.FilterPaymentsRow) int64 { - + extractCursor := func(row sqlc.FilterPaymentsRow) int64 { return row.Payment.ID } @@ -549,9 +547,7 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, } // collectFunc extracts the payment ID from each payment row. - collectFunc := func(row sqlc.FilterPaymentsRow) (int64, - error) { - + collectFunc := func(row sqlc.FilterPaymentsRow) (int64, error) { return row.Payment.ID, nil } From 5d328c976e4376caeab825fe96cd055896d492de Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 20 Nov 2025 23:22:42 +0100 Subject: [PATCH 25/78] paymentsdb: remove pointer receiver dependecy to make it more robust We remove the SQLStore from most of the helper functions. This also makes sure we do not accidentally create a new db tx but use the provided db SQLQueries parameter. --- payments/db/sql_store.go | 92 ++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 15346efbfb0..e911833fb79 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -143,19 +143,22 @@ var _ DB = (*SQLStore)(nil) // including attempts, hops, and custom records from the database. // This is a convenience wrapper around the batch loading functions for single // payment operations. -func (s *SQLStore) fetchPaymentWithCompleteData(ctx context.Context, - db SQLQueries, dbPayment sqlc.PaymentAndIntent) (*MPPayment, error) { +func fetchPaymentWithCompleteData(ctx context.Context, + cfg *sqldb.QueryConfig, db SQLQueries, + dbPayment sqlc.PaymentAndIntent) (*MPPayment, error) { payment := dbPayment.GetPayment() // Load batch data for this single payment. - batchData, err := s.loadPaymentsBatchData(ctx, db, []int64{payment.ID}) + batchData, err := loadPaymentsBatchData( + ctx, cfg, db, []int64{payment.ID}, + ) if err != nil { return nil, fmt.Errorf("failed to load batch data: %w", err) } // Build the payment from the batch data. - return s.buildPaymentFromBatchData(dbPayment, batchData) + return buildPaymentFromBatchData(dbPayment, batchData) } // paymentsBatchData holds all the batch-loaded data for multiple payments. @@ -180,12 +183,12 @@ type paymentsBatchData struct { // loadPaymentCustomRecords loads payment-level custom records for a given // set of payment IDs. It uses a batch query to fetch all custom records for // the given payment IDs. -func (s *SQLStore) loadPaymentCustomRecords(ctx context.Context, - db SQLQueries, paymentIDs []int64, +func loadPaymentCustomRecords(ctx context.Context, + cfg *sqldb.QueryConfig, db SQLQueries, paymentIDs []int64, batchData *paymentsBatchData) error { return sqldb.ExecuteBatchQuery( - ctx, s.cfg.QueryCfg, paymentIDs, + ctx, cfg, paymentIDs, func(id int64) int64 { return id }, func(ctx context.Context, ids []int64) ( []sqlc.PaymentFirstHopCustomRecord, error) { @@ -214,13 +217,14 @@ func (s *SQLStore) loadPaymentCustomRecords(ctx context.Context, // loadHtlcAttempts loads HTLC attempts for all payments and returns all // attempt indices. It uses a batch query to fetch all attempts for the given // payment IDs. -func (s *SQLStore) loadHtlcAttempts(ctx context.Context, db SQLQueries, - paymentIDs []int64, batchData *paymentsBatchData) ([]int64, error) { +func loadHtlcAttempts(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, paymentIDs []int64, + batchData *paymentsBatchData) ([]int64, error) { var allAttemptIndices []int64 err := sqldb.ExecuteBatchQuery( - ctx, s.cfg.QueryCfg, paymentIDs, + ctx, cfg, paymentIDs, func(id int64) int64 { return id }, func(ctx context.Context, ids []int64) ( []sqlc.FetchHtlcAttemptsForPaymentsRow, error) { @@ -246,13 +250,14 @@ func (s *SQLStore) loadHtlcAttempts(ctx context.Context, db SQLQueries, // loadHopsForAttempts loads hops for all attempts and returns all hop IDs. // It uses a batch query to fetch all hops for the given attempt indices. -func (s *SQLStore) loadHopsForAttempts(ctx context.Context, db SQLQueries, - attemptIndices []int64, batchData *paymentsBatchData) ([]int64, error) { +func loadHopsForAttempts(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, attemptIndices []int64, + batchData *paymentsBatchData) ([]int64, error) { var hopIDs []int64 err := sqldb.ExecuteBatchQuery( - ctx, s.cfg.QueryCfg, attemptIndices, + ctx, cfg, attemptIndices, func(idx int64) int64 { return idx }, func(ctx context.Context, indices []int64) ( []sqlc.FetchHopsForAttemptsRow, error) { @@ -279,11 +284,11 @@ func (s *SQLStore) loadHopsForAttempts(ctx context.Context, db SQLQueries, // loadHopCustomRecords loads hop-level custom records for all hops. It uses // a batch query to fetch all custom records for the given hop IDs. -func (s *SQLStore) loadHopCustomRecords(ctx context.Context, db SQLQueries, - hopIDs []int64, batchData *paymentsBatchData) error { +func loadHopCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, hopIDs []int64, batchData *paymentsBatchData) error { return sqldb.ExecuteBatchQuery( - ctx, s.cfg.QueryCfg, hopIDs, + ctx, cfg, hopIDs, func(id int64) int64 { return id }, func(ctx context.Context, ids []int64) ( []sqlc.PaymentHopCustomRecord, error) { @@ -313,11 +318,12 @@ func (s *SQLStore) loadHopCustomRecords(ctx context.Context, db SQLQueries, // loadRouteCustomRecords loads route-level first hop custom records for all // attempts. It uses a batch query to fetch all custom records for the given // attempt indices. -func (s *SQLStore) loadRouteCustomRecords(ctx context.Context, db SQLQueries, - attemptIndices []int64, batchData *paymentsBatchData) error { +func loadRouteCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, attemptIndices []int64, + batchData *paymentsBatchData) error { return sqldb.ExecuteBatchQuery( - ctx, s.cfg.QueryCfg, attemptIndices, + ctx, cfg, attemptIndices, func(idx int64) int64 { return idx }, func(ctx context.Context, indices []int64) ( []sqlc.PaymentAttemptFirstHopCustomRecord, error) { @@ -342,8 +348,8 @@ func (s *SQLStore) loadRouteCustomRecords(ctx context.Context, db SQLQueries, // loadPaymentsBatchData loads all related data for multiple payments in batch. // It uses a batch queries to fetch all data for the given payment IDs. -func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, - paymentIDs []int64) (*paymentsBatchData, error) { +func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, paymentIDs []int64) (*paymentsBatchData, error) { batchData := &paymentsBatchData{ paymentCustomRecords: make( @@ -368,15 +374,15 @@ func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, } // Load payment-level custom records. - err := s.loadPaymentCustomRecords(ctx, db, paymentIDs, batchData) + err := loadPaymentCustomRecords(ctx, cfg, db, paymentIDs, batchData) if err != nil { return nil, fmt.Errorf("failed to fetch payment custom "+ "records: %w", err) } // Load HTLC attempts and collect attempt indices. - allAttemptIndices, err := s.loadHtlcAttempts( - ctx, db, paymentIDs, batchData, + allAttemptIndices, err := loadHtlcAttempts( + ctx, cfg, db, paymentIDs, batchData, ) if err != nil { return nil, fmt.Errorf("failed to fetch HTLC attempts: %w", @@ -389,8 +395,8 @@ func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, } // Load hops for all attempts and collect hop IDs. - hopIDs, err := s.loadHopsForAttempts( - ctx, db, allAttemptIndices, batchData, + hopIDs, err := loadHopsForAttempts( + ctx, cfg, db, allAttemptIndices, batchData, ) if err != nil { return nil, fmt.Errorf("failed to fetch hops for attempts: %w", @@ -399,7 +405,7 @@ func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, // Load hop-level custom records if there are any hops. if len(hopIDs) > 0 { - err = s.loadHopCustomRecords(ctx, db, hopIDs, batchData) + err = loadHopCustomRecords(ctx, cfg, db, hopIDs, batchData) if err != nil { return nil, fmt.Errorf("failed to fetch hop custom "+ "records: %w", err) @@ -407,7 +413,7 @@ func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, } // Load route-level first hop custom records. - err = s.loadRouteCustomRecords(ctx, db, allAttemptIndices, batchData) + err = loadRouteCustomRecords(ctx, cfg, db, allAttemptIndices, batchData) if err != nil { return nil, fmt.Errorf("failed to fetch route custom "+ "records: %w", err) @@ -418,7 +424,7 @@ func (s *SQLStore) loadPaymentsBatchData(ctx context.Context, db SQLQueries, // buildPaymentFromBatchData builds a complete MPPayment from a database payment // and pre-loaded batch data. -func (s *SQLStore) buildPaymentFromBatchData(dbPayment sqlc.PaymentAndIntent, +func buildPaymentFromBatchData(dbPayment sqlc.PaymentAndIntent, batchData *paymentsBatchData) (*MPPayment, error) { // The query will only return BOLT 11 payment intents or intents with @@ -555,7 +561,9 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, batchDataFunc := func(ctx context.Context, paymentIDs []int64) ( *paymentsBatchData, error) { - return s.loadPaymentsBatchData(ctx, db, paymentIDs) + return loadPaymentsBatchData( + ctx, s.cfg.QueryCfg, db, paymentIDs, + ) } // processPayment processes each payment with the batch-loaded @@ -565,7 +573,7 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, batchData *paymentsBatchData) error { // Build the payment from the pre-loaded batch data. - mpPayment, err := s.buildPaymentFromBatchData( + mpPayment, err := buildPaymentFromBatchData( dbPayment, batchData, ) if err != nil { @@ -708,8 +716,8 @@ func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { return ErrPaymentNotInitiated } - mpPayment, err = s.fetchPaymentWithCompleteData( - ctx, db, dbPayment, + mpPayment, err = fetchPaymentWithCompleteData( + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to fetch payment with "+ @@ -1176,8 +1184,8 @@ func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { - // First Fetch the payment and check if it is registrable. - existingPayment, err := db.FetchPayment(ctx, paymentHash[:]) + // Make sure the payment exists. + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) if err != nil { return fmt.Errorf("failed to fetch payment: %w", err) } @@ -1188,8 +1196,8 @@ func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, // TODO(ziggie): We could improve the query here since only // the last hop data is needed here not the complete payment // data. - mpPayment, err = s.fetchPaymentWithCompleteData( - ctx, db, existingPayment, + mpPayment, err = fetchPaymentWithCompleteData( + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to fetch payment with "+ @@ -1211,7 +1219,7 @@ func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, sessionKeyBytes := sessionKey.Serialize() _, err = db.InsertHtlcAttempt(ctx, sqlc.InsertHtlcAttemptParams{ - PaymentID: existingPayment.Payment.ID, + PaymentID: dbPayment.Payment.ID, AttemptIndex: int64(attempt.AttemptID), SessionKey: sessionKeyBytes, AttemptTime: attempt.AttemptTime, @@ -1268,8 +1276,8 @@ func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, // interface), we still refetch here to guarantee we return // consistent, up-to-date data that reflects all changes made // within this transaction. - mpPayment, err = s.fetchPaymentWithCompleteData( - ctx, db, existingPayment, + mpPayment, err = fetchPaymentWithCompleteData( + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to fetch payment with "+ @@ -1331,8 +1339,8 @@ func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, } // Fetch the complete payment after we settled the attempt. - mpPayment, err = s.fetchPaymentWithCompleteData( - ctx, db, dbPayment, + mpPayment, err = fetchPaymentWithCompleteData( + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to fetch payment with "+ From caf73c59da2e2779607c3c261804ec35ce600a5c Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 20 Nov 2025 23:43:25 +0100 Subject: [PATCH 26/78] paymentsdb: rename paymentsBatchData We rename this variable to paymentsDetailsData because we will also need to batch load the core payment and intent data in future commits and this renaming should make it clear that this does match payment related data but not the core data which is in the payment and in the intent table. --- payments/db/sql_store.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index e911833fb79..7f2e1c8a38a 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -161,8 +161,11 @@ func fetchPaymentWithCompleteData(ctx context.Context, return buildPaymentFromBatchData(dbPayment, batchData) } -// paymentsBatchData holds all the batch-loaded data for multiple payments. -type paymentsBatchData struct { +// paymentsDetailsData holds all the batch-loaded data for multiple payments. +// This does not include the core payment and intent data which is fetched +// separately. It includes the additional data like attempts, hops, hop custom +// records, and route custom records. +type paymentsDetailsData struct { // paymentCustomRecords maps payment ID to its custom records. paymentCustomRecords map[int64][]sqlc.PaymentFirstHopCustomRecord @@ -185,7 +188,7 @@ type paymentsBatchData struct { // the given payment IDs. func loadPaymentCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, paymentIDs []int64, - batchData *paymentsBatchData) error { + batchData *paymentsDetailsData) error { return sqldb.ExecuteBatchQuery( ctx, cfg, paymentIDs, @@ -219,7 +222,7 @@ func loadPaymentCustomRecords(ctx context.Context, // payment IDs. func loadHtlcAttempts(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, paymentIDs []int64, - batchData *paymentsBatchData) ([]int64, error) { + batchData *paymentsDetailsData) ([]int64, error) { var allAttemptIndices []int64 @@ -252,7 +255,7 @@ func loadHtlcAttempts(ctx context.Context, cfg *sqldb.QueryConfig, // It uses a batch query to fetch all hops for the given attempt indices. func loadHopsForAttempts(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, attemptIndices []int64, - batchData *paymentsBatchData) ([]int64, error) { + batchData *paymentsDetailsData) ([]int64, error) { var hopIDs []int64 @@ -285,7 +288,7 @@ func loadHopsForAttempts(ctx context.Context, cfg *sqldb.QueryConfig, // loadHopCustomRecords loads hop-level custom records for all hops. It uses // a batch query to fetch all custom records for the given hop IDs. func loadHopCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, - db SQLQueries, hopIDs []int64, batchData *paymentsBatchData) error { + db SQLQueries, hopIDs []int64, batchData *paymentsDetailsData) error { return sqldb.ExecuteBatchQuery( ctx, cfg, hopIDs, @@ -320,7 +323,7 @@ func loadHopCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, // attempt indices. func loadRouteCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, attemptIndices []int64, - batchData *paymentsBatchData) error { + batchData *paymentsDetailsData) error { return sqldb.ExecuteBatchQuery( ctx, cfg, attemptIndices, @@ -349,9 +352,9 @@ func loadRouteCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, // loadPaymentsBatchData loads all related data for multiple payments in batch. // It uses a batch queries to fetch all data for the given payment IDs. func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, - db SQLQueries, paymentIDs []int64) (*paymentsBatchData, error) { + db SQLQueries, paymentIDs []int64) (*paymentsDetailsData, error) { - batchData := &paymentsBatchData{ + batchData := &paymentsDetailsData{ paymentCustomRecords: make( map[int64][]sqlc.PaymentFirstHopCustomRecord, ), @@ -425,7 +428,7 @@ func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, // buildPaymentFromBatchData builds a complete MPPayment from a database payment // and pre-loaded batch data. func buildPaymentFromBatchData(dbPayment sqlc.PaymentAndIntent, - batchData *paymentsBatchData) (*MPPayment, error) { + batchData *paymentsDetailsData) (*MPPayment, error) { // The query will only return BOLT 11 payment intents or intents with // no intent type set. @@ -559,7 +562,7 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, // batchDataFunc loads all related data for a batch of payments. batchDataFunc := func(ctx context.Context, paymentIDs []int64) ( - *paymentsBatchData, error) { + *paymentsDetailsData, error) { return loadPaymentsBatchData( ctx, s.cfg.QueryCfg, db, paymentIDs, @@ -570,7 +573,7 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, // data. processPayment := func(ctx context.Context, dbPayment sqlc.FilterPaymentsRow, - batchData *paymentsBatchData) error { + batchData *paymentsDetailsData) error { // Build the payment from the pre-loaded batch data. mpPayment, err := buildPaymentFromBatchData( From fd6796e5613a58eb6b9c86b015f312813d2975b7 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:19:11 +0200 Subject: [PATCH 27/78] multi: implement Fail method for sql backend --- payments/db/sql_store.go | 66 +++++++++++++++++++++++++++++++++ sqldb/sqlc/payments.sql.go | 13 +++++++ sqldb/sqlc/querier.go | 1 + sqldb/sqlc/queries/payments.sql | 3 ++ 4 files changed, 83 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 7f2e1c8a38a..469f0bea729 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -77,6 +77,8 @@ type SQLQueries interface { SettleAttempt(ctx context.Context, arg sqlc.SettleAttemptParams) error + FailPayment(ctx context.Context, arg sqlc.FailPaymentParams) (sql.Result, error) + DeletePayment(ctx context.Context, paymentID int64) error // DeleteFailedAttempts removes all failed HTLCs from the db for a @@ -1360,3 +1362,67 @@ func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, return mpPayment, nil } + +// Fail records the ultimate reason why a payment failed. This method stores +// the failure reason for record keeping but does not enforce that all HTLC +// attempts are resolved - HTLCs may still be in flight when this is called. +// +// The payment's actual status transition to StatusFailed is determined by the +// payment state calculation, which considers both the recorded failure reason +// and the current state of all HTLC attempts. The status will transition to +// StatusFailed once all HTLCs are resolved and/or a failure reason is recorded. +// +// NOTE: According to the interface contract, this should only be called when +// all active attempts are already failed. However, the implementation allows +// concurrent calls and does not validate this precondition, enabling the last +// failing attempt to record the failure reason without synchronization. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface. It represents +// step 4 in the payment lifecycle control flow. +func (s *SQLStore) Fail(paymentHash lntypes.Hash, + reason FailureReason) (*MPPayment, error) { + + ctx := context.TODO() + + var mpPayment *MPPayment + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + result, err := db.FailPayment(ctx, sqlc.FailPaymentParams{ + PaymentIdentifier: paymentHash[:], + FailReason: sqldb.SQLInt32(reason), + }) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrPaymentNotInitiated + } + + payment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch payment: %w", err) + } + mpPayment, err = fetchPaymentWithCompleteData( + ctx, s.cfg.QueryCfg, db, payment, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment with "+ + "complete data: %w", err) + } + + return nil + }, func() { + mpPayment = nil + }) + if err != nil { + return nil, fmt.Errorf("failed to fail payment: %w", err) + } + + return mpPayment, nil +} diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index fb117bcaad0..fd150592daf 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -85,6 +85,19 @@ func (q *Queries) FailAttempt(ctx context.Context, arg FailAttemptParams) error return err } +const failPayment = `-- name: FailPayment :execresult +UPDATE payments SET fail_reason = $1 WHERE payment_identifier = $2 +` + +type FailPaymentParams struct { + FailReason sql.NullInt32 + PaymentIdentifier []byte +} + +func (q *Queries) FailPayment(ctx context.Context, arg FailPaymentParams) (sql.Result, error) { + return q.db.ExecContext(ctx, failPayment, arg.FailReason, arg.PaymentIdentifier) +} + const fetchAllInflightAttempts = `-- name: FetchAllInflightAttempts :many SELECT ha.id, diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index cc52f06f1b9..da022c1de05 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -35,6 +35,7 @@ type Querier interface { DeleteUnconnectedNodes(ctx context.Context) ([][]byte, error) DeleteZombieChannel(ctx context.Context, arg DeleteZombieChannelParams) (sql.Result, error) FailAttempt(ctx context.Context, arg FailAttemptParams) error + FailPayment(ctx context.Context, arg FailPaymentParams) (sql.Result, error) FetchAMPSubInvoiceHTLCs(ctx context.Context, arg FetchAMPSubInvoiceHTLCsParams) ([]FetchAMPSubInvoiceHTLCsRow, error) FetchAMPSubInvoices(ctx context.Context, arg FetchAMPSubInvoicesParams) ([]AmpSubInvoice, error) // Fetch all inflight attempts across all payments diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index b0183ccd2a6..1cc8e2330d2 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -352,3 +352,6 @@ VALUES ( @htlc_fail_reason, @failure_msg ); + +-- name: FailPayment :execresult +UPDATE payments SET fail_reason = $1 WHERE payment_identifier = $2; From 53a7877af0caa1afb2cb26eeb8a0e6833cc9753f Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:22:24 +0200 Subject: [PATCH 28/78] paymentsdb: implement FailAttempt for sql backend --- payments/db/sql_store.go | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 469f0bea729..3caf35ce124 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1,6 +1,7 @@ package paymentsdb import ( + "bytes" "context" "database/sql" "errors" @@ -76,6 +77,7 @@ type SQLQueries interface { InsertPaymentHopCustomRecord(ctx context.Context, arg sqlc.InsertPaymentHopCustomRecordParams) error SettleAttempt(ctx context.Context, arg sqlc.SettleAttemptParams) error + FailAttempt(ctx context.Context, arg sqlc.FailAttemptParams) error FailPayment(ctx context.Context, arg sqlc.FailPaymentParams) (sql.Result, error) @@ -1363,6 +1365,97 @@ func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, return mpPayment, nil } +// FailAttempt marks the specified HTLC attempt as failed, recording the +// failure reason, failure time, optional failure message, and the index of the +// node in the route that generated the failure. This information is atomically +// saved to the database for debugging and route optimization purposes. +// +// For single-path payments, failing the only attempt may lead to the payment +// being retried or ultimately failed via the Fail method. For multi-shard +// (MPP/AMP) payments, individual shard failures don't necessarily fail the +// entire payment; additional attempts can be registered until sufficient shards +// succeed or the payment is permanently failed. +// +// Returns the updated MPPayment with the attempt marked as failed and the +// payment state recalculated. The payment status remains StatusInFlight if +// other attempts are still in flight, or may transition based on the overall +// payment state. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface. It represents +// step 3b in the payment lifecycle control flow (step 3a is SettleAttempt), +// called after RegisterAttempt when an HTLC fails. +func (s *SQLStore) FailAttempt(paymentHash lntypes.Hash, + attemptID uint64, failInfo *HTLCFailInfo) (*MPPayment, error) { + + ctx := context.TODO() + + var mpPayment *MPPayment + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch payment: %w", err) + } + + paymentStatus, err := computePaymentStatusFromDB( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + // We check if the payment is updatable before failing the + // attempt. + if err := paymentStatus.updatable(); err != nil { + return fmt.Errorf("payment is not updatable: %w", err) + } + + var failureMsg bytes.Buffer + if failInfo.Message != nil { + err := lnwire.EncodeFailureMessage( + &failureMsg, failInfo.Message, 0, + ) + if err != nil { + return fmt.Errorf("failed to encode "+ + "failure message: %w", err) + } + } + + err = db.FailAttempt(ctx, sqlc.FailAttemptParams{ + AttemptIndex: int64(attemptID), + ResolutionTime: time.Now(), + ResolutionType: int32(HTLCAttemptResolutionFailed), + FailureSourceIndex: sqldb.SQLInt32( + failInfo.FailureSourceIndex, + ), + HtlcFailReason: sqldb.SQLInt32(failInfo.Reason), + FailureMsg: failureMsg.Bytes(), + }) + if err != nil { + return fmt.Errorf("failed to fail attempt: %w", err) + } + + mpPayment, err = fetchPaymentWithCompleteData( + ctx, s.cfg.QueryCfg, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to fetch payment with "+ + "complete data: %w", err) + } + + return nil + }, func() { + mpPayment = nil + }) + if err != nil { + return nil, fmt.Errorf("failed to fail attempt: %w", err) + } + + return mpPayment, nil +} + // Fail records the ultimate reason why a payment failed. This method stores // the failure reason for record keeping but does not enforce that all HTLC // attempts are resolved - HTLCs may still be in flight when this is called. From ff12082452c0e9db8fd772779bb7e7bfad1b62b4 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:20:14 +0200 Subject: [PATCH 29/78] paymentsdb: implement DeletePayments for sql backend --- payments/db/sql_store.go | 298 +++++++++++++++++++++++++++----- sqldb/sqlc/payments.sql.go | 37 ++-- sqldb/sqlc/querier.go | 6 +- sqldb/sqlc/queries/payments.sql | 10 +- 4 files changed, 296 insertions(+), 55 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 3caf35ce124..3de8524de97 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -52,7 +52,7 @@ type SQLQueries interface { CountPayments(ctx context.Context) (int64, error) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptsForPaymentsRow, error) - FetchHtlcAttemptResolutionsForPayment(ctx context.Context, paymentID int64) ([]sql.NullInt32, error) + FetchHtlcAttemptResolutionsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptResolutionsForPaymentsRow, error) FetchAllInflightAttempts(ctx context.Context) ([]sqlc.PaymentHtlcAttempt, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.FetchHopsForAttemptsRow, error) @@ -353,6 +353,108 @@ func loadRouteCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, ) } +// paymentStatusData holds lightweight resolution data for computing +// payment status efficiently during deletion operations. +type paymentStatusData struct { + // resolutionTypes maps payment ID to a list of resolution types + // for that payment's HTLC attempts. + resolutionTypes map[int64][]sql.NullInt32 +} + +// batchLoadPaymentResolutions loads only HTLC resolution types for multiple +// payments. This is a lightweight alternative to loadPaymentsBatchData that's +// optimized for operations that only need to determine payment status. +func batchLoadPaymentResolutions(ctx context.Context, db SQLQueries, + paymentIDs []int64) (*paymentStatusData, error) { + + batchData := &paymentStatusData{ + resolutionTypes: make(map[int64][]sql.NullInt32), + } + + if len(paymentIDs) == 0 { + return batchData, nil + } + + // Fetch resolution types for all payments in a single batch query. + resolutions, err := db.FetchHtlcAttemptResolutionsForPayments( + ctx, paymentIDs, + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch HTLC resolutions: %w", + err) + } + + // Group resolutions by payment ID. + for _, res := range resolutions { + batchData.resolutionTypes[res.PaymentID] = append( + batchData.resolutionTypes[res.PaymentID], + res.ResolutionType, + ) + } + + return batchData, nil +} + +// loadPaymentResolutions is a single-payment wrapper around +// batchLoadPaymentResolutions for convenience and to prevent duplicate +// queries. +func loadPaymentResolutions(ctx context.Context, db SQLQueries, + paymentID int64) ([]sql.NullInt32, error) { + + batchData, err := batchLoadPaymentResolutions( + ctx, db, []int64{paymentID}, + ) + if err != nil { + return nil, err + } + + return batchData.resolutionTypes[paymentID], nil +} + +// computePaymentStatusFromResolutions determines the payment status from +// resolution types and failure reason without building the complete MPPayment +// structure. This is a lightweight version that builds minimal HTLCAttempt +// structures and delegates to decidePaymentStatus for consistency. +func computePaymentStatusFromResolutions(resolutionTypes []sql.NullInt32, + failReason sql.NullInt32) (PaymentStatus, error) { + + // Build minimal HTLCAttempt slice with only resolution info. + htlcs := make([]HTLCAttempt, len(resolutionTypes)) + for i, resType := range resolutionTypes { + if !resType.Valid { + // NULL resolution_type means in-flight (no Settle, no + // Failure). + continue + } + + switch HTLCAttemptResolutionType(resType.Int32) { + case HTLCAttemptResolutionSettled: + // Mark as settled (preimage details not needed for + // status). + htlcs[i].Settle = &HTLCSettleInfo{} + + case HTLCAttemptResolutionFailed: + // Mark as failed (failure details not needed for + // status). + htlcs[i].Failure = &HTLCFailInfo{} + + default: + return 0, fmt.Errorf("unknown resolution type: %v", + resType.Int32) + } + } + + // Convert fail reason to FailureReason pointer. + var failureReason *FailureReason + if failReason.Valid { + reason := FailureReason(failReason.Int32) + failureReason = &reason + } + + // Use the existing status decision logic. + return decidePaymentStatus(htlcs, failureReason) +} + // loadPaymentsBatchData loads all related data for multiple payments in batch. // It uses a batch queries to fetch all data for the given payment IDs. func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, @@ -809,52 +911,25 @@ func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { // data from the database. This is a lightweight query optimized for SQL that // doesn't load route data, making it significantly more efficient than // FetchPayment when only the status is needed. -func computePaymentStatusFromDB(ctx context.Context, db SQLQueries, - dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { +func computePaymentStatusFromDB(ctx context.Context, + db SQLQueries, dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { payment := dbPayment.GetPayment() - resolutionTypes, err := db.FetchHtlcAttemptResolutionsForPayment( - ctx, payment.ID, - ) + // Use the batch-optimized wrapper to fetch resolution types. + resolutionTypes, err := loadPaymentResolutions(ctx, db, payment.ID) if err != nil { - return 0, fmt.Errorf("failed to fetch htlc resolutions: %w", + return 0, fmt.Errorf("failed to load payment resolutions: %w", err) } - // Build minimal HTLCAttempt slice with only resolution info. - htlcs := make([]HTLCAttempt, len(resolutionTypes)) - for i, resType := range resolutionTypes { - if !resType.Valid { - // NULL resolution_type means in-flight (no Settle, no - // Failure). - continue - } - - switch HTLCAttemptResolutionType(resType.Int32) { - case HTLCAttemptResolutionSettled: - // Mark as settled (preimage details not needed for - // status). - htlcs[i].Settle = &HTLCSettleInfo{} - - case HTLCAttemptResolutionFailed: - // Mark as failed (failure details not needed for - // status). - htlcs[i].Failure = &HTLCFailInfo{} - } - } - - // Convert fail reason to FailureReason pointer. - var failureReason *FailureReason - if payment.FailReason.Valid { - reason := FailureReason(payment.FailReason.Int32) - failureReason = &reason - } - - // Use the existing status decision logic. - status, err := decidePaymentStatus(htlcs, failureReason) + // Use the lightweight status computation. + status, err := computePaymentStatusFromResolutions( + resolutionTypes, payment.FailReason, + ) if err != nil { - return 0, fmt.Errorf("failed to decide payment status: %w", err) + return 0, fmt.Errorf("failed to compute payment status: %w", + err) } return status, nil @@ -1519,3 +1594,148 @@ func (s *SQLStore) Fail(paymentHash lntypes.Hash, return mpPayment, nil } + +// DeletePayments performs a batch deletion of payments or their failed HTLC +// attempts from the database based on the specified flags. This is a bulk +// operation that iterates through all payments and selectively deletes based +// on the criteria. +// The behavior is controlled by two flags: +// +// If failedAttemptsOnly is true, only failed HTLC attempts are deleted while +// preserving the payment records and any successful or in-flight attempts. +// The return value is always 0 when deleting attempts only. +// +// If failedAttemptsOnly is false, entire payment records are deleted including +// all associated data (HTLCs, metadata, intents). The return value is the +// number of payments deleted. +// +// The failedOnly flag further filters which payments are processed: +// - failedOnly=true, failedAttemptsOnly=true: Delete failed attempts for +// StatusFailed payments only +// - failedOnly=false, failedAttemptsOnly=true: Delete failed attempts for +// all removable payments +// - failedOnly=true, failedAttemptsOnly=false: Delete entire payment records +// for StatusFailed payments only +// - failedOnly=false, failedAttemptsOnly=false: Delete all removable payment +// records (StatusInitiated, StatusSucceeded, StatusFailed) +// +// Safety checks applied to all operations: +// - Payments with StatusInFlight are always skipped (cannot be safely deleted +// while HTLCs are on the network) +// - The payment status must pass the removable() check +// +// Returns the number of complete payments deleted (0 if only deleting failed +// attempts). This is useful for cleanup operations, administrative maintenance, +// or freeing up database storage. +// +// This method is part of the PaymentWriter interface, which is embedded in +// the DB interface. +// +// TODO(ziggie): batch this call instead in the background so for dbs with +// many payments it doesn't block the main thread. +func (s *SQLStore) DeletePayments(failedOnly, failedHtlcsOnly bool) (int, + error) { + + var numPayments int + ctx := context.TODO() + + extractCursor := func(row sqlc.FilterPaymentsRow) int64 { + return row.Payment.ID + } + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + // collectFunc extracts the payment ID from each payment row. + collectFunc := func(row sqlc.FilterPaymentsRow) (int64, error) { + return row.Payment.ID, nil + } + + // batchDataFunc loads only HTLC resolution types for a batch + // of payments, which is sufficient to determine payment status. + batchDataFunc := func(ctx context.Context, paymentIDs []int64) ( + *paymentStatusData, error) { + + return batchLoadPaymentResolutions( + ctx, db, paymentIDs, + ) + } + + // processPayment processes each payment with the lightweight + // batch-loaded resolution data. + processPayment := func(ctx context.Context, + dbPayment sqlc.FilterPaymentsRow, + batchData *paymentStatusData) error { + + payment := dbPayment.Payment + + // Compute the payment status from resolution types and + // failure reason without building the complete payment. + resolutionTypes := batchData.resolutionTypes[payment.ID] + status, err := computePaymentStatusFromResolutions( + resolutionTypes, payment.FailReason, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + // Payments which are not final yet cannot be deleted. + // we skip them. + if err := status.removable(); err != nil { + return nil + } + + // If we are only deleting failed payments, we skip + // if the payment is not failed. + if failedOnly && status != StatusFailed { + return nil + } + + // If we are only deleting failed HTLCs, we delete them + // and return early. + if failedHtlcsOnly { + return db.DeleteFailedAttempts( + ctx, payment.ID, + ) + } + + // Otherwise we delete the payment. + err = db.DeletePayment(ctx, payment.ID) + if err != nil { + return fmt.Errorf("failed to delete "+ + "payment: %w", err) + } + + numPayments++ + + return nil + } + + queryFunc := func(ctx context.Context, lastID int64, + limit int32) ([]sqlc.FilterPaymentsRow, error) { + + filterParams := sqlc.FilterPaymentsParams{ + NumLimit: limit, + IndexOffsetGet: sqldb.SQLInt64( + lastID, + ), + } + + return db.FilterPayments(ctx, filterParams) + } + + return sqldb.ExecuteCollectAndBatchWithSharedDataQuery( + ctx, s.cfg.QueryCfg, int64(-1), queryFunc, + extractCursor, collectFunc, batchDataFunc, + processPayment, + ) + }, func() { + numPayments = 0 + }) + if err != nil { + return 0, fmt.Errorf("failed to delete payments "+ + "(failedOnly: %v, failedHtlcsOnly: %v): %w", + failedOnly, failedHtlcsOnly, err) + } + + return numPayments, nil +} diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index fd150592daf..d3a7364c2c9 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -297,29 +297,46 @@ func (q *Queries) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices [ return items, nil } -const fetchHtlcAttemptResolutionsForPayment = `-- name: FetchHtlcAttemptResolutionsForPayment :many +const fetchHtlcAttemptResolutionsForPayments = `-- name: FetchHtlcAttemptResolutionsForPayments :many SELECT + ha.payment_id, hr.resolution_type FROM payment_htlc_attempts ha LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index -WHERE ha.payment_id = $1 -ORDER BY ha.attempt_time ASC +WHERE ha.payment_id IN (/*SLICE:payment_ids*/?) ` -// Lightweight query to fetch only HTLC resolution status. -func (q *Queries) FetchHtlcAttemptResolutionsForPayment(ctx context.Context, paymentID int64) ([]sql.NullInt32, error) { - rows, err := q.db.QueryContext(ctx, fetchHtlcAttemptResolutionsForPayment, paymentID) +type FetchHtlcAttemptResolutionsForPaymentsRow struct { + PaymentID int64 + ResolutionType sql.NullInt32 +} + +// Batch query to fetch only HTLC resolution status for multiple payments. +// We don't need to order by payment_id and attempt_time because we will +// group the resolutions by payment_id in the background. +func (q *Queries) FetchHtlcAttemptResolutionsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptResolutionsForPaymentsRow, error) { + query := fetchHtlcAttemptResolutionsForPayments + var queryParams []interface{} + if len(paymentIds) > 0 { + for _, v := range paymentIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:payment_ids*/?", makeQueryParams(len(queryParams), len(paymentIds)), 1) + } else { + query = strings.Replace(query, "/*SLICE:payment_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } defer rows.Close() - var items []sql.NullInt32 + var items []FetchHtlcAttemptResolutionsForPaymentsRow for rows.Next() { - var resolution_type sql.NullInt32 - if err := rows.Scan(&resolution_type); err != nil { + var i FetchHtlcAttemptResolutionsForPaymentsRow + if err := rows.Scan(&i.PaymentID, &i.ResolutionType); err != nil { return nil, err } - items = append(items, resolution_type) + items = append(items, i) } if err := rows.Close(); err != nil { return nil, err diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index da022c1de05..05c9c81a573 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -42,8 +42,10 @@ type Querier interface { FetchAllInflightAttempts(ctx context.Context) ([]PaymentHtlcAttempt, error) FetchHopLevelCustomRecords(ctx context.Context, hopIds []int64) ([]PaymentHopCustomRecord, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]FetchHopsForAttemptsRow, error) - // Lightweight query to fetch only HTLC resolution status. - FetchHtlcAttemptResolutionsForPayment(ctx context.Context, paymentID int64) ([]sql.NullInt32, error) + // Batch query to fetch only HTLC resolution status for multiple payments. + // We don't need to order by payment_id and attempt_time because we will + // group the resolutions by payment_id in the background. + FetchHtlcAttemptResolutionsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptResolutionsForPaymentsRow, error) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptsForPaymentsRow, error) FetchPayment(ctx context.Context, paymentIdentifier []byte) (FetchPaymentRow, error) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIds []int64) ([]PaymentFirstHopCustomRecord, error) diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index 1cc8e2330d2..7fb979ac67b 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -75,14 +75,16 @@ LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_i WHERE ha.payment_id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/) ORDER BY ha.payment_id ASC, ha.attempt_time ASC; --- name: FetchHtlcAttemptResolutionsForPayment :many --- Lightweight query to fetch only HTLC resolution status. +-- name: FetchHtlcAttemptResolutionsForPayments :many +-- Batch query to fetch only HTLC resolution status for multiple payments. +-- We don't need to order by payment_id and attempt_time because we will +-- group the resolutions by payment_id in the background. SELECT + ha.payment_id, hr.resolution_type FROM payment_htlc_attempts ha LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index -WHERE ha.payment_id = $1 -ORDER BY ha.attempt_time ASC; +WHERE ha.payment_id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/); -- name: FetchAllInflightAttempts :many -- Fetch all inflight attempts across all payments From 218a30d16e6f5752425b65390c632d989071ce42 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 13 Nov 2025 15:05:35 +0100 Subject: [PATCH 30/78] paymentsdb: add a wrapper to the fetchpayment method We wrap the fetchPayment db call and catch the case where no errors are found in the db, where we now return the ErrPaymentNotInitiated error. --- payments/db/sql_store.go | 54 +++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 3de8524de97..fc7d4bc0fc1 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -801,6 +801,24 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, }, nil } +// fetchPaymentByHash fetches a payment by its hash from the database. It is a +// convenience wrapper around the FetchPayment method and checks for +// no rows error and returns ErrPaymentNotInitiated if no payment is found. +func fetchPaymentByHash(ctx context.Context, db SQLQueries, + paymentHash lntypes.Hash) (sqlc.FetchPaymentRow, error) { + + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return dbPayment, fmt.Errorf("failed to fetch payment: %w", err) + } + + if errors.Is(err, sql.ErrNoRows) { + return dbPayment, ErrPaymentNotInitiated + } + + return dbPayment, nil +} + // FetchPayment retrieves a complete payment record from the database by its // payment hash. The returned MPPayment includes all payment metadata such as // creation info, payment status, current state, all HTLC attempts (both @@ -816,13 +834,9 @@ func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { - dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("failed to fetch payment: %w", err) - } - - if errors.Is(err, sql.ErrNoRows) { - return ErrPaymentNotInitiated + dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) + if err != nil { + return err } mpPayment, err = fetchPaymentWithCompleteData( @@ -878,9 +892,9 @@ func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { } err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { - dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) if err != nil { - return fmt.Errorf("failed to fetch payment: %w", err) + return err } paymentStatus, err := computePaymentStatusFromDB( @@ -897,7 +911,7 @@ func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { } // Then we delete the failed attempts for this payment. - return db.DeleteFailedAttempts(ctx, dbPayment.Payment.ID) + return db.DeleteFailedAttempts(ctx, dbPayment.GetPayment().ID) }, sqldb.NoOpReset) if err != nil { return fmt.Errorf("failed to delete failed attempts for "+ @@ -967,10 +981,9 @@ func (s *SQLStore) DeletePayment(paymentHash lntypes.Hash, ctx := context.TODO() err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { - dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) if err != nil { - return fmt.Errorf("failed to fetch "+ - "payment: %w", err) + return err } paymentStatus, err := computePaymentStatusFromDB( @@ -989,13 +1002,13 @@ func (s *SQLStore) DeletePayment(paymentHash lntypes.Hash, // If we are only deleting failed HTLCs, we delete them. if failedHtlcsOnly { return db.DeleteFailedAttempts( - ctx, dbPayment.Payment.ID, + ctx, dbPayment.GetPayment().ID, ) } // In case we are not deleting failed HTLCs, we delete the // payment which will cascade delete all related data. - return db.DeletePayment(ctx, dbPayment.Payment.ID) + return db.DeletePayment(ctx, dbPayment.GetPayment().ID) }, sqldb.NoOpReset) if err != nil { return fmt.Errorf("failed to delete failed attempts for "+ @@ -1269,7 +1282,7 @@ func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, // Make sure the payment exists. dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) if err != nil { - return fmt.Errorf("failed to fetch payment: %w", err) + return err } // We fetch the complete payment to determine if the payment is @@ -1393,9 +1406,9 @@ func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { - dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) if err != nil { - return fmt.Errorf("failed to fetch payment: %w", err) + return err } paymentStatus, err := computePaymentStatusFromDB( @@ -1468,9 +1481,10 @@ func (s *SQLStore) FailAttempt(paymentHash lntypes.Hash, var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { - dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + // Make sure the payment exists. + dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) if err != nil { - return fmt.Errorf("failed to fetch payment: %w", err) + return err } paymentStatus, err := computePaymentStatusFromDB( From b02531cc859b03b40c930d9a30db43313d6ea20c Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 21 Nov 2025 00:00:31 +0100 Subject: [PATCH 31/78] paymentsdb: rename functions and variables We take inspiration from the graph sql implementation and name the variables accordingly. --- payments/db/sql_store.go | 66 ++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index fc7d4bc0fc1..393ac365b98 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -154,7 +154,7 @@ func fetchPaymentWithCompleteData(ctx context.Context, payment := dbPayment.GetPayment() // Load batch data for this single payment. - batchData, err := loadPaymentsBatchData( + batchData, err := batchLoadPaymentDetailsData( ctx, cfg, db, []int64{payment.ID}, ) if err != nil { @@ -187,10 +187,10 @@ type paymentsDetailsData struct { routeCustomRecords map[int64][]sqlc.PaymentAttemptFirstHopCustomRecord } -// loadPaymentCustomRecords loads payment-level custom records for a given +// batchLoadPaymentCustomRecords loads payment-level custom records for a given // set of payment IDs. It uses a batch query to fetch all custom records for // the given payment IDs. -func loadPaymentCustomRecords(ctx context.Context, +func batchLoadPaymentCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, paymentIDs []int64, batchData *paymentsDetailsData) error { @@ -221,10 +221,10 @@ func loadPaymentCustomRecords(ctx context.Context, ) } -// loadHtlcAttempts loads HTLC attempts for all payments and returns all +// batchLoadHtlcAttempts loads HTLC attempts for all payments and returns all // attempt indices. It uses a batch query to fetch all attempts for the given // payment IDs. -func loadHtlcAttempts(ctx context.Context, cfg *sqldb.QueryConfig, +func batchLoadHtlcAttempts(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, paymentIDs []int64, batchData *paymentsDetailsData) ([]int64, error) { @@ -255,9 +255,9 @@ func loadHtlcAttempts(ctx context.Context, cfg *sqldb.QueryConfig, return allAttemptIndices, err } -// loadHopsForAttempts loads hops for all attempts and returns all hop IDs. +// batchLoadHopsForAttempts loads hops for all attempts and returns all hop IDs. // It uses a batch query to fetch all hops for the given attempt indices. -func loadHopsForAttempts(ctx context.Context, cfg *sqldb.QueryConfig, +func batchLoadHopsForAttempts(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, attemptIndices []int64, batchData *paymentsDetailsData) ([]int64, error) { @@ -289,9 +289,9 @@ func loadHopsForAttempts(ctx context.Context, cfg *sqldb.QueryConfig, return hopIDs, err } -// loadHopCustomRecords loads hop-level custom records for all hops. It uses -// a batch query to fetch all custom records for the given hop IDs. -func loadHopCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, +// batchLoadHopCustomRecords loads hop-level custom records for all hops. It +// uses a batch query to fetch all custom records for the given hop IDs. +func batchLoadHopCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, hopIDs []int64, batchData *paymentsDetailsData) error { return sqldb.ExecuteBatchQuery( @@ -322,10 +322,10 @@ func loadHopCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, ) } -// loadRouteCustomRecords loads route-level first hop custom records for all -// attempts. It uses a batch query to fetch all custom records for the given +// batchLoadRouteCustomRecords loads route-level first hop custom records for +// all attempts. It uses a batch query to fetch all custom records for the given // attempt indices. -func loadRouteCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, +func batchLoadRouteCustomRecords(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, attemptIndices []int64, batchData *paymentsDetailsData) error { @@ -362,8 +362,8 @@ type paymentStatusData struct { } // batchLoadPaymentResolutions loads only HTLC resolution types for multiple -// payments. This is a lightweight alternative to loadPaymentsBatchData that's -// optimized for operations that only need to determine payment status. +// payments. This is a lightweight alternative to batchLoadPaymentsRelatedData +// that's optimized for operations that only need to determine payment status. func batchLoadPaymentResolutions(ctx context.Context, db SQLQueries, paymentIDs []int64) (*paymentStatusData, error) { @@ -396,8 +396,8 @@ func batchLoadPaymentResolutions(ctx context.Context, db SQLQueries, } // loadPaymentResolutions is a single-payment wrapper around -// batchLoadPaymentResolutions for convenience and to prevent duplicate -// queries. +// batchLoadPaymentResolutions for convenience and to prevent duplicate queries +// so we reuse the same batch query for all payments. func loadPaymentResolutions(ctx context.Context, db SQLQueries, paymentID int64) ([]sql.NullInt32, error) { @@ -455,9 +455,9 @@ func computePaymentStatusFromResolutions(resolutionTypes []sql.NullInt32, return decidePaymentStatus(htlcs, failureReason) } -// loadPaymentsBatchData loads all related data for multiple payments in batch. -// It uses a batch queries to fetch all data for the given payment IDs. -func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, +// batchLoadPaymentDetailsData loads all related data for multiple payments in +// batch. It uses a batch queries to fetch all data for the given payment IDs. +func batchLoadPaymentDetailsData(ctx context.Context, cfg *sqldb.QueryConfig, db SQLQueries, paymentIDs []int64) (*paymentsDetailsData, error) { batchData := &paymentsDetailsData{ @@ -483,14 +483,16 @@ func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, } // Load payment-level custom records. - err := loadPaymentCustomRecords(ctx, cfg, db, paymentIDs, batchData) + err := batchLoadPaymentCustomRecords( + ctx, cfg, db, paymentIDs, batchData, + ) if err != nil { return nil, fmt.Errorf("failed to fetch payment custom "+ "records: %w", err) } // Load HTLC attempts and collect attempt indices. - allAttemptIndices, err := loadHtlcAttempts( + allAttemptIndices, err := batchLoadHtlcAttempts( ctx, cfg, db, paymentIDs, batchData, ) if err != nil { @@ -504,7 +506,7 @@ func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, } // Load hops for all attempts and collect hop IDs. - hopIDs, err := loadHopsForAttempts( + hopIDs, err := batchLoadHopsForAttempts( ctx, cfg, db, allAttemptIndices, batchData, ) if err != nil { @@ -514,7 +516,7 @@ func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, // Load hop-level custom records if there are any hops. if len(hopIDs) > 0 { - err = loadHopCustomRecords(ctx, cfg, db, hopIDs, batchData) + err = batchLoadHopCustomRecords(ctx, cfg, db, hopIDs, batchData) if err != nil { return nil, fmt.Errorf("failed to fetch hop custom "+ "records: %w", err) @@ -522,7 +524,9 @@ func loadPaymentsBatchData(ctx context.Context, cfg *sqldb.QueryConfig, } // Load route-level first hop custom records. - err = loadRouteCustomRecords(ctx, cfg, db, allAttemptIndices, batchData) + err = batchLoadRouteCustomRecords( + ctx, cfg, db, allAttemptIndices, batchData, + ) if err != nil { return nil, fmt.Errorf("failed to fetch route custom "+ "records: %w", err) @@ -670,7 +674,7 @@ func (s *SQLStore) QueryPayments(ctx context.Context, query Query) (Response, batchDataFunc := func(ctx context.Context, paymentIDs []int64) ( *paymentsDetailsData, error) { - return loadPaymentsBatchData( + return batchLoadPaymentDetailsData( ctx, s.cfg.QueryCfg, db, paymentIDs, ) } @@ -925,13 +929,15 @@ func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { // data from the database. This is a lightweight query optimized for SQL that // doesn't load route data, making it significantly more efficient than // FetchPayment when only the status is needed. -func computePaymentStatusFromDB(ctx context.Context, - db SQLQueries, dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { +func computePaymentStatusFromDB(ctx context.Context, db SQLQueries, + dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { payment := dbPayment.GetPayment() - // Use the batch-optimized wrapper to fetch resolution types. - resolutionTypes, err := loadPaymentResolutions(ctx, db, payment.ID) + // Load the resolution types for the payment. + resolutionTypes, err := loadPaymentResolutions( + ctx, db, payment.ID, + ) if err != nil { return 0, fmt.Errorf("failed to load payment resolutions: %w", err) From 82ee78c5ef59467f2b68de1d28aad52d854a8dc6 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 21 Nov 2025 00:12:51 +0100 Subject: [PATCH 32/78] paymentsdb: use batch function when querying for resolutions --- payments/db/sql_store.go | 70 ++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 393ac365b98..759800aec1b 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -364,45 +364,59 @@ type paymentStatusData struct { // batchLoadPaymentResolutions loads only HTLC resolution types for multiple // payments. This is a lightweight alternative to batchLoadPaymentsRelatedData // that's optimized for operations that only need to determine payment status. -func batchLoadPaymentResolutions(ctx context.Context, db SQLQueries, - paymentIDs []int64) (*paymentStatusData, error) { +func batchLoadPaymentResolutions(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, paymentIDs []int64) (*paymentStatusData, error) { - batchData := &paymentStatusData{ + batchStatusData := &paymentStatusData{ resolutionTypes: make(map[int64][]sql.NullInt32), } if len(paymentIDs) == 0 { - return batchData, nil + return batchStatusData, nil } - // Fetch resolution types for all payments in a single batch query. - resolutions, err := db.FetchHtlcAttemptResolutionsForPayments( - ctx, paymentIDs, + // Use a batch query to fetch all resolution types for the given payment + // IDs. + err := sqldb.ExecuteBatchQuery( + ctx, cfg, paymentIDs, + func(id int64) int64 { return id }, + func(ctx context.Context, ids []int64) ( + []sqlc.FetchHtlcAttemptResolutionsForPaymentsRow, + error) { + + return db.FetchHtlcAttemptResolutionsForPayments( + ctx, ids, + ) + }, + //nolint:ll + func(ctx context.Context, + res sqlc.FetchHtlcAttemptResolutionsForPaymentsRow) error { + + // Group resolutions by payment ID. + batchStatusData.resolutionTypes[res.PaymentID] = append( + batchStatusData.resolutionTypes[res.PaymentID], + res.ResolutionType, + ) + + return nil + }, ) if err != nil { return nil, fmt.Errorf("failed to fetch HTLC resolutions: %w", err) } - // Group resolutions by payment ID. - for _, res := range resolutions { - batchData.resolutionTypes[res.PaymentID] = append( - batchData.resolutionTypes[res.PaymentID], - res.ResolutionType, - ) - } - - return batchData, nil + return batchStatusData, nil } // loadPaymentResolutions is a single-payment wrapper around // batchLoadPaymentResolutions for convenience and to prevent duplicate queries // so we reuse the same batch query for all payments. -func loadPaymentResolutions(ctx context.Context, db SQLQueries, - paymentID int64) ([]sql.NullInt32, error) { +func loadPaymentResolutions(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, paymentID int64) ([]sql.NullInt32, error) { batchData, err := batchLoadPaymentResolutions( - ctx, db, []int64{paymentID}, + ctx, cfg, db, []int64{paymentID}, ) if err != nil { return nil, err @@ -902,7 +916,7 @@ func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { } paymentStatus, err := computePaymentStatusFromDB( - ctx, db, dbPayment, + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to compute payment "+ @@ -929,14 +943,14 @@ func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { // data from the database. This is a lightweight query optimized for SQL that // doesn't load route data, making it significantly more efficient than // FetchPayment when only the status is needed. -func computePaymentStatusFromDB(ctx context.Context, db SQLQueries, - dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { +func computePaymentStatusFromDB(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { payment := dbPayment.GetPayment() // Load the resolution types for the payment. resolutionTypes, err := loadPaymentResolutions( - ctx, db, payment.ID, + ctx, cfg, db, payment.ID, ) if err != nil { return 0, fmt.Errorf("failed to load payment resolutions: %w", @@ -993,7 +1007,7 @@ func (s *SQLStore) DeletePayment(paymentHash lntypes.Hash, } paymentStatus, err := computePaymentStatusFromDB( - ctx, db, dbPayment, + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to compute payment "+ @@ -1057,7 +1071,7 @@ func (s *SQLStore) InitPayment(paymentHash lntypes.Hash, // status to see if we can re-initialize. case err == nil: paymentStatus, err := computePaymentStatusFromDB( - ctx, db, existingPayment, + ctx, s.cfg.QueryCfg, db, existingPayment, ) if err != nil { return fmt.Errorf("failed to compute payment "+ @@ -1418,7 +1432,7 @@ func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, } paymentStatus, err := computePaymentStatusFromDB( - ctx, db, dbPayment, + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to compute payment "+ @@ -1494,7 +1508,7 @@ func (s *SQLStore) FailAttempt(paymentHash lntypes.Hash, } paymentStatus, err := computePaymentStatusFromDB( - ctx, db, dbPayment, + ctx, s.cfg.QueryCfg, db, dbPayment, ) if err != nil { return fmt.Errorf("failed to compute payment "+ @@ -1675,7 +1689,7 @@ func (s *SQLStore) DeletePayments(failedOnly, failedHtlcsOnly bool) (int, *paymentStatusData, error) { return batchLoadPaymentResolutions( - ctx, db, paymentIDs, + ctx, s.cfg.QueryCfg, db, paymentIDs, ) } From 969b00e4a5497c463dcbafa71ed3312127729297 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:23:55 +0200 Subject: [PATCH 33/78] paymentsdb: implement FetchInFlightPayments for sql backend --- payments/db/sql_store.go | 221 +++++++++++++++++++++++++++++++- sqldb/sqlc/db_custom.go | 16 +-- sqldb/sqlc/payments.sql.go | 50 +++++--- sqldb/sqlc/querier.go | 8 +- sqldb/sqlc/queries/payments.sql | 33 +++-- 5 files changed, 288 insertions(+), 40 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 759800aec1b..e7aab1cfbe5 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -53,7 +53,7 @@ type SQLQueries interface { FetchHtlcAttemptsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptsForPaymentsRow, error) FetchHtlcAttemptResolutionsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptResolutionsForPaymentsRow, error) - FetchAllInflightAttempts(ctx context.Context) ([]sqlc.PaymentHtlcAttempt, error) + FetchAllInflightAttempts(ctx context.Context, arg sqlc.FetchAllInflightAttemptsParams) ([]sqlc.PaymentHtlcAttempt, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.FetchHopsForAttemptsRow, error) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIDs []int64) ([]sqlc.PaymentFirstHopCustomRecord, error) @@ -165,8 +165,88 @@ func fetchPaymentWithCompleteData(ctx context.Context, return buildPaymentFromBatchData(dbPayment, batchData) } -// paymentsDetailsData holds all the batch-loaded data for multiple payments. -// This does not include the core payment and intent data which is fetched +// paymentsCompleteData holds the full payment data when batch loading base +// payment data and all the related data for a payment. +type paymentsCompleteData struct { + *paymentsBaseData + *paymentsDetailsData +} + +// batchLoadPayments loads the full payment data for a batch of payment IDs. +func batchLoadPayments(ctx context.Context, cfg *sqldb.QueryConfig, + db SQLQueries, paymentIDs []int64) (*paymentsCompleteData, error) { + + baseData, err := batchLoadpaymentsBaseData(ctx, cfg, db, paymentIDs) + if err != nil { + return nil, fmt.Errorf("failed to load payment base data: %w", + err) + } + + batchData, err := batchLoadPaymentDetailsData(ctx, cfg, db, paymentIDs) + if err != nil { + return nil, fmt.Errorf("failed to load payment batch data: %w", + err) + } + + return &paymentsCompleteData{ + paymentsBaseData: baseData, + paymentsDetailsData: batchData, + }, nil +} + +// paymentsBaseData holds the base payment and intent data for a batch of +// payments. +type paymentsBaseData struct { + // paymentsAndIntents maps payment ID to its payment and intent data. + paymentsAndIntents map[int64]sqlc.PaymentAndIntent +} + +// batchLoadpaymentsBaseData loads the base payment and payment intent data for +// a batch of payment IDs. This complements loadPaymentsBatchData which loads +// related data (attempts, hops, custom records) but not the payment table +// and payment intent table data. +func batchLoadpaymentsBaseData(ctx context.Context, + cfg *sqldb.QueryConfig, db SQLQueries, + paymentIDs []int64) (*paymentsBaseData, error) { + + baseData := &paymentsBaseData{ + paymentsAndIntents: make(map[int64]sqlc.PaymentAndIntent), + } + + if len(paymentIDs) == 0 { + return baseData, nil + } + + err := sqldb.ExecuteBatchQuery( + ctx, cfg, paymentIDs, + func(id int64) int64 { return id }, + func(ctx context.Context, ids []int64) ( + []sqlc.FetchPaymentsByIDsRow, error) { + + records, err := db.FetchPaymentsByIDs( + ctx, ids, + ) + + return records, err + }, + func(ctx context.Context, + payment sqlc.FetchPaymentsByIDsRow) error { + + baseData.paymentsAndIntents[payment.ID] = payment + + return nil + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch payment base "+ + "data: %w", err) + } + + return baseData, nil +} + +// paymentsRelatedData holds all the batch-loaded data for multiple payments. +// This does not include the base payment and intent data which is fetched // separately. It includes the additional data like attempts, hops, hop custom // records, and route custom records. type paymentsDetailsData struct { @@ -874,6 +954,141 @@ func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { return mpPayment, nil } +// FetchInFlightPayments retrieves all payments that have HTLC attempts +// currently in flight (not yet settled or failed). These are payments with at +// least one HTLC attempt that has been registered but has no resolution record. +// +// The SQLStore implementation provides a significant performance improvement +// over the KVStore implementation by using targeted SQL queries instead of +// scanning all payments. +// +// This method is part of the PaymentReader interface, which is embedded in the +// DB interface. It's typically called during node startup to resume monitoring +// of pending payments and ensure HTLCs are properly tracked. +// +// TODO(ziggie): Consider changing the interface to use a callback or iterator +// pattern instead of returning all payments at once. This would allow +// processing payments one at a time without holding them all in memory +// simultaneously: +// - Callback: func FetchInFlightPayments(ctx, func(*MPPayment) error) error +// - Iterator: func FetchInFlightPayments(ctx) (PaymentIterator, error) +// +// While inflight payments are typically a small subset, this would improve +// memory efficiency for nodes with unusually high numbers of concurrent +// payments and would better leverage the existing pagination infrastructure. +func (s *SQLStore) FetchInFlightPayments() ([]*MPPayment, + error) { + + ctx := context.TODO() + + var mpPayments []*MPPayment + + err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { + // Track which payment IDs we've already processed across all + // pages to avoid loading the same payment multiple times when + // multiple inflight attempts belong to the same payment. + processedPayments := make(map[int64]*MPPayment) + + extractCursor := func(row sqlc.PaymentHtlcAttempt) int64 { + return row.AttemptIndex + } + + // collectFunc extracts the payment ID from each attempt row. + collectFunc := func(row sqlc.PaymentHtlcAttempt) ( + int64, error) { + + return row.PaymentID, nil + } + + // batchDataFunc loads payment data for a batch of payment IDs, + // but only for IDs we haven't processed yet. + batchDataFunc := func(ctx context.Context, + paymentIDs []int64) (*paymentsCompleteData, error) { + + // Filter out already-processed payment IDs. + uniqueIDs := make([]int64, 0, len(paymentIDs)) + for _, id := range paymentIDs { + _, processed := processedPayments[id] + if !processed { + uniqueIDs = append(uniqueIDs, id) + } + } + + // If uniqueIDs is empty, the batch load will return + // empty batch data. + return batchLoadPayments( + ctx, s.cfg.QueryCfg, db, uniqueIDs, + ) + } + + // processAttempt processes each attempt. We only build and + // store the payment once per unique payment ID. + processAttempt := func(ctx context.Context, + row sqlc.PaymentHtlcAttempt, + batchData *paymentsCompleteData) error { + + // Skip if we've already processed this payment. + _, processed := processedPayments[row.PaymentID] + if processed { + return nil + } + + dbPayment := batchData.paymentsAndIntents[row.PaymentID] + + // Build the payment from batch data. + mpPayment, err := buildPaymentFromBatchData( + dbPayment, batchData.paymentsDetailsData, + ) + if err != nil { + return fmt.Errorf("failed to build payment: %w", + err) + } + + // Store in our processed map. + processedPayments[row.PaymentID] = mpPayment + + return nil + } + + queryFunc := func(ctx context.Context, lastAttemptIndex int64, + limit int32) ([]sqlc.PaymentHtlcAttempt, + error) { + + return db.FetchAllInflightAttempts(ctx, + sqlc.FetchAllInflightAttemptsParams{ + AttemptIndex: lastAttemptIndex, + Limit: limit, + }, + ) + } + + err := sqldb.ExecuteCollectAndBatchWithSharedDataQuery( + ctx, s.cfg.QueryCfg, int64(-1), queryFunc, + extractCursor, collectFunc, batchDataFunc, + processAttempt, + ) + if err != nil { + return err + } + + // Convert map to slice. + mpPayments = make([]*MPPayment, 0, len(processedPayments)) + for _, payment := range processedPayments { + mpPayments = append(mpPayments, payment) + } + + return nil + }, func() { + mpPayments = nil + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch inflight "+ + "payments: %w", err) + } + + return mpPayments, nil +} + // DeleteFailedAttempts removes all failed HTLC attempts from the database for // the specified payment, while preserving the payment record itself and any // successful or in-flight attempts. diff --git a/sqldb/sqlc/db_custom.go b/sqldb/sqlc/db_custom.go index 7888f81f45d..1b8d465e73c 100644 --- a/sqldb/sqlc/db_custom.go +++ b/sqldb/sqlc/db_custom.go @@ -222,18 +222,16 @@ func (r FetchPaymentRow) GetPaymentIntent() PaymentIntent { } } -// GetPayment returns the Payment associated with this interface. -// -// NOTE: This method is part of the PaymentAndIntent interface. func (r FetchPaymentsByIDsRow) GetPayment() Payment { - return r.Payment + return Payment{ + ID: r.ID, + AmountMsat: r.AmountMsat, + CreatedAt: r.CreatedAt, + PaymentIdentifier: r.PaymentIdentifier, + FailReason: r.FailReason, + } } -// GetPaymentIntent returns the PaymentIntent associated with this payment. -// If the payment has no intent (IntentType is NULL), this returns a zero-value -// PaymentIntent. -// -// NOTE: This method is part of the PaymentAndIntent interface. func (r FetchPaymentsByIDsRow) GetPaymentIntent() PaymentIntent { if !r.IntentType.Valid { return PaymentIntent{} diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index d3a7364c2c9..b9ec3149932 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -115,12 +115,20 @@ WHERE NOT EXISTS ( SELECT 1 FROM payment_htlc_attempt_resolutions hr WHERE hr.attempt_index = ha.attempt_index ) +AND ha.attempt_index > $1 ORDER BY ha.attempt_index ASC +LIMIT $2 ` -// Fetch all inflight attempts across all payments -func (q *Queries) FetchAllInflightAttempts(ctx context.Context) ([]PaymentHtlcAttempt, error) { - rows, err := q.db.QueryContext(ctx, fetchAllInflightAttempts) +type FetchAllInflightAttemptsParams struct { + AttemptIndex int64 + Limit int32 +} + +// Fetch all inflight attempts with their payment data using pagination. +// Returns attempt data joined with payment and intent data to avoid separate queries. +func (q *Queries) FetchAllInflightAttempts(ctx context.Context, arg FetchAllInflightAttemptsParams) ([]PaymentHtlcAttempt, error) { + rows, err := q.db.QueryContext(ctx, fetchAllInflightAttempts, arg.AttemptIndex, arg.Limit) if err != nil { return nil, err } @@ -522,20 +530,32 @@ func (q *Queries) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, pa const fetchPaymentsByIDs = `-- name: FetchPaymentsByIDs :many SELECT - p.id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, - i.intent_type AS "intent_type", - i.intent_payload AS "intent_payload" + p.id, + p.amount_msat, + p.created_at, + p.payment_identifier, + p.fail_reason, + pi.intent_type, + pi.intent_payload FROM payments p -LEFT JOIN payment_intents i ON i.payment_id = p.id +LEFT JOIN payment_intents pi ON pi.payment_id = p.id WHERE p.id IN (/*SLICE:payment_ids*/?) +ORDER BY p.id ASC ` type FetchPaymentsByIDsRow struct { - Payment Payment - IntentType sql.NullInt16 - IntentPayload []byte + ID int64 + AmountMsat int64 + CreatedAt time.Time + PaymentIdentifier []byte + FailReason sql.NullInt32 + IntentType sql.NullInt16 + IntentPayload []byte } +// Batch fetch payment and intent data for a set of payment IDs. +// Used to avoid fetching redundant payment data when processing multiple +// attempts for the same payment. func (q *Queries) FetchPaymentsByIDs(ctx context.Context, paymentIds []int64) ([]FetchPaymentsByIDsRow, error) { query := fetchPaymentsByIDs var queryParams []interface{} @@ -556,11 +576,11 @@ func (q *Queries) FetchPaymentsByIDs(ctx context.Context, paymentIds []int64) ([ for rows.Next() { var i FetchPaymentsByIDsRow if err := rows.Scan( - &i.Payment.ID, - &i.Payment.AmountMsat, - &i.Payment.CreatedAt, - &i.Payment.PaymentIdentifier, - &i.Payment.FailReason, + &i.ID, + &i.AmountMsat, + &i.CreatedAt, + &i.PaymentIdentifier, + &i.FailReason, &i.IntentType, &i.IntentPayload, ); err != nil { diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 05c9c81a573..3be738ba93c 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -38,8 +38,9 @@ type Querier interface { FailPayment(ctx context.Context, arg FailPaymentParams) (sql.Result, error) FetchAMPSubInvoiceHTLCs(ctx context.Context, arg FetchAMPSubInvoiceHTLCsParams) ([]FetchAMPSubInvoiceHTLCsRow, error) FetchAMPSubInvoices(ctx context.Context, arg FetchAMPSubInvoicesParams) ([]AmpSubInvoice, error) - // Fetch all inflight attempts across all payments - FetchAllInflightAttempts(ctx context.Context) ([]PaymentHtlcAttempt, error) + // Fetch all inflight attempts with their payment data using pagination. + // Returns attempt data joined with payment and intent data to avoid separate queries. + FetchAllInflightAttempts(ctx context.Context, arg FetchAllInflightAttemptsParams) ([]PaymentHtlcAttempt, error) FetchHopLevelCustomRecords(ctx context.Context, hopIds []int64) ([]PaymentHopCustomRecord, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]FetchHopsForAttemptsRow, error) // Batch query to fetch only HTLC resolution status for multiple payments. @@ -49,6 +50,9 @@ type Querier interface { FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptsForPaymentsRow, error) FetchPayment(ctx context.Context, paymentIdentifier []byte) (FetchPaymentRow, error) FetchPaymentLevelFirstHopCustomRecords(ctx context.Context, paymentIds []int64) ([]PaymentFirstHopCustomRecord, error) + // Batch fetch payment and intent data for a set of payment IDs. + // Used to avoid fetching redundant payment data when processing multiple + // attempts for the same payment. FetchPaymentsByIDs(ctx context.Context, paymentIds []int64) ([]FetchPaymentsByIDsRow, error) // FetchPendingInvoices returns all invoices in a pending state (open or // accepted). The invoices_state_idx index on the state column makes this a diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index 7fb979ac67b..419f7bf1aca 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -40,15 +40,6 @@ FROM payments p LEFT JOIN payment_intents i ON i.payment_id = p.id WHERE p.payment_identifier = $1; --- name: FetchPaymentsByIDs :many -SELECT - sqlc.embed(p), - i.intent_type AS "intent_type", - i.intent_payload AS "intent_payload" -FROM payments p -LEFT JOIN payment_intents i ON i.payment_id = p.id -WHERE p.id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/); - -- name: CountPayments :one SELECT COUNT(*) FROM payments; @@ -86,8 +77,26 @@ FROM payment_htlc_attempts ha LEFT JOIN payment_htlc_attempt_resolutions hr ON hr.attempt_index = ha.attempt_index WHERE ha.payment_id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/); +-- name: FetchPaymentsByIDs :many +-- Batch fetch payment and intent data for a set of payment IDs. +-- Used to avoid fetching redundant payment data when processing multiple +-- attempts for the same payment. +SELECT + p.id, + p.amount_msat, + p.created_at, + p.payment_identifier, + p.fail_reason, + pi.intent_type, + pi.intent_payload +FROM payments p +LEFT JOIN payment_intents pi ON pi.payment_id = p.id +WHERE p.id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/) +ORDER BY p.id ASC; + -- name: FetchAllInflightAttempts :many --- Fetch all inflight attempts across all payments +-- Fetch all inflight attempts with their payment data using pagination. +-- Returns attempt data joined with payment and intent data to avoid separate queries. SELECT ha.id, ha.attempt_index, @@ -104,7 +113,9 @@ WHERE NOT EXISTS ( SELECT 1 FROM payment_htlc_attempt_resolutions hr WHERE hr.attempt_index = ha.attempt_index ) -ORDER BY ha.attempt_index ASC; +AND ha.attempt_index > $1 +ORDER BY ha.attempt_index ASC +LIMIT $2; -- name: FetchHopsForAttempts :many SELECT From 4e52896cc118d65c823990d43eacf0d6799d5da1 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 21 Nov 2025 01:15:55 +0100 Subject: [PATCH 34/78] paymentsdb: added unit test for computePaymentStatusFromResolutions --- payments/db/sql_store_test.go | 229 ++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 payments/db/sql_store_test.go diff --git a/payments/db/sql_store_test.go b/payments/db/sql_store_test.go new file mode 100644 index 00000000000..0f4f310f38e --- /dev/null +++ b/payments/db/sql_store_test.go @@ -0,0 +1,229 @@ +//go:build test_db_sqlite || test_db_postgres + +package paymentsdb + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestComputePaymentStatus tests the SQL to domain type conversion logic in +// computePaymentStatusFromResolutions. This is a pure unit test with no +// database interaction. However the function is only used in the SQL store and +// used sql data types so we test it in a sql specific file. +func TestComputePaymentStatus(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + resolutionTypes []sql.NullInt32 + failReason sql.NullInt32 + expectedStatus PaymentStatus + expectError bool + }{ + { + name: "all NULL resolutions means in-flight", + resolutionTypes: []sql.NullInt32{ + {Valid: false}, // NULL = in-flight + {Valid: false}, + }, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusInFlight, + }, + { + name: "settled resolution without fail reason", + resolutionTypes: []sql.NullInt32{{ + Int32: int32(HTLCAttemptResolutionSettled), + Valid: true, + }}, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusSucceeded, + }, + { + name: "failed resolution without fail reason", + resolutionTypes: []sql.NullInt32{{ + Int32: int32(HTLCAttemptResolutionFailed), + Valid: true, + }}, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusInFlight, + }, + { + name: "failed resolution with fail reason", + resolutionTypes: []sql.NullInt32{{ + Int32: int32(HTLCAttemptResolutionFailed), + Valid: true, + }}, + failReason: sql.NullInt32{ + Int32: int32(FailureReasonNoRoute), + Valid: true, + }, + expectedStatus: StatusFailed, + }, + { + name: "mixed: in-flight and settled", + resolutionTypes: []sql.NullInt32{ + {Valid: false}, // in-flight + { + Int32: int32( + HTLCAttemptResolutionSettled, + ), + Valid: true, + }, + }, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusInFlight, + }, + { + name: "mixed: in-flight and failed", + resolutionTypes: []sql.NullInt32{ + {Valid: false}, // in-flight + { + Int32: int32( + HTLCAttemptResolutionFailed, + ), + Valid: true, + }, + }, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusInFlight, + }, + { + name: "mixed: settled and failed", + resolutionTypes: []sql.NullInt32{ + { + Int32: int32( + HTLCAttemptResolutionSettled, + ), + Valid: true, + }, + { + Int32: int32( + HTLCAttemptResolutionFailed, + ), + Valid: true, + }, + }, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusSucceeded, + }, + { + name: "no resolutions, no fail reason, " + + "means initiated", + resolutionTypes: []sql.NullInt32{}, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusInitiated, + }, + { + name: "no resolutions with fail reason, " + + "means failed", + resolutionTypes: []sql.NullInt32{}, + failReason: sql.NullInt32{ + Int32: int32(FailureReasonNoRoute), + Valid: true, + }, + expectedStatus: StatusFailed, + }, + { + name: "unknown resolution type returns error", + resolutionTypes: []sql.NullInt32{ + {Int32: 999, Valid: true}, // invalid type + }, + failReason: sql.NullInt32{Valid: false}, + expectError: true, + }, + { + name: "all three states: in-flight, settled, failed", + resolutionTypes: []sql.NullInt32{ + { + Valid: false, // in-flight + }, + { + Int32: int32( + HTLCAttemptResolutionSettled, + ), + Valid: true, + }, + { + Int32: int32( + HTLCAttemptResolutionFailed, + ), + Valid: true, + }, + }, + failReason: sql.NullInt32{ + Int32: int32(FailureReasonTimeout), + Valid: true, + }, + expectedStatus: StatusInFlight, + }, + { + name: "multiple settled HTLCs", + resolutionTypes: []sql.NullInt32{ + { + Int32: int32( + HTLCAttemptResolutionSettled, + ), + Valid: true, + }, + { + Int32: int32( + HTLCAttemptResolutionSettled, + ), + Valid: true, + }, + { + Int32: int32( + HTLCAttemptResolutionSettled, + ), + Valid: true, + }, + }, + failReason: sql.NullInt32{Valid: false}, + expectedStatus: StatusSucceeded, + }, + { + name: "multiple failed HTLCs with fail reason", + resolutionTypes: []sql.NullInt32{ + { + Int32: int32( + HTLCAttemptResolutionFailed, + ), + Valid: true, + }, + { + Int32: int32( + HTLCAttemptResolutionFailed, + ), + Valid: true, + }, + }, + failReason: sql.NullInt32{ + Int32: int32(FailureReasonNoRoute), + Valid: true, + }, + expectedStatus: StatusFailed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status, err := computePaymentStatusFromResolutions( + tc.resolutionTypes, tc.failReason, + ) + + if tc.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedStatus, status, + "got %s, want %s", status, tc.expectedStatus) + }) + } +} From c919d25619710f345173065637ea53e2f582dad1 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 13 Nov 2025 18:06:23 +0100 Subject: [PATCH 35/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index dfd8c714e22..c3cea8e3aeb 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -207,6 +207,9 @@ SQL Backend](https://github.com/lightningnetwork/lnd/pull/10287) * Implement insert methods for the [payments db SQL Backend](https://github.com/lightningnetwork/lnd/pull/10291) + * Implement third(final) Part of SQL backend [payment + functions](https://github.com/lightningnetwork/lnd/pull/10368) + ## Code Health From 9ad3f2195b0b69a0453f1dfe7adde39ea1da054b Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:25:08 +0200 Subject: [PATCH 36/78] paymentsdb: remove kvstore from sql db implementation Now that every method of the interface was implemented we can remove the embedded reference we put into place for the sql store implementation so that the interface would succeed. This is now removed. --- payments/db/sql_store.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index e7aab1cfbe5..0109ca1afa1 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -97,10 +97,6 @@ type BatchedSQLQueries interface { // SQLStore represents a storage backend. type SQLStore struct { - // TODO(ziggie): Remove the KVStore once all the interface functions are - // implemented. - KVStore - cfg *SQLStoreConfig db BatchedSQLQueries From fdfa1430f866e332f6c2945b06e6cd4634cc1732 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:08:59 +0200 Subject: [PATCH 37/78] paymentsdb: refactor test helpers Since now the sql backend is more strict in using the same session key we refactor the helper so that we can easily change the session key for every new attempt. --- payments/db/kv_store_test.go | 22 +-- payments/db/payment_test.go | 255 ++++++++++++++++++++++++++--------- 2 files changed, 203 insertions(+), 74 deletions(-) diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index 2c2895175ad..ccd2e450966 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -2,6 +2,7 @@ package paymentsdb import ( "bytes" + "crypto/sha256" "encoding/binary" "io" "math" @@ -65,10 +66,15 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { var numSuccess, numInflight int for _, p := range payments { - info, attempt, preimg, err := genInfo(t) - if err != nil { - t.Fatalf("unable to generate htlc message: %v", err) - } + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + attempt, err := genAttemptWithHash( + t, 0, genSessionKey(t), rhash, + ) + require.NoError(t, err) // Sends base htlc message which initiate StatusInFlight. err = paymentDB.InitPayment(info.PaymentIdentifier, info) @@ -478,7 +484,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { paymentDB := NewKVTestDB(t) // Generate a test payment which does not have duplicates. - noDuplicates, _, _, err := genInfo(t) + noDuplicates, _, err := genInfo(t) require.NoError(t, err) // Create a new payment entry in the database. @@ -494,7 +500,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { require.NoError(t, err) // Generate a test payment which we will add duplicates to. - hasDuplicates, _, preimg, err := genInfo(t) + hasDuplicates, preimg, err := genInfo(t) require.NoError(t, err) // Create a new payment entry in the database. @@ -652,7 +658,7 @@ func putDuplicatePayment(t *testing.T, duplicateBucket kvdb.RwBucket, require.NoError(t, err) // Generate fake information for the duplicate payment. - info, _, _, err := genInfo(t) + info, _, err := genInfo(t) require.NoError(t, err) // Write the payment info to disk under the creation info key. This code @@ -960,7 +966,7 @@ func TestQueryPayments(t *testing.T) { for i := 0; i < nonDuplicatePayments; i++ { // Generate a test payment. - info, _, preimg, err := genInfo(t) + info, preimg, err := genInfo(t) if err != nil { t.Fatalf("unable to create test "+ "payment: %v", err) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index e6a2e735a9c..22ef30f4805 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -116,13 +116,20 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { attemptID := uint64(0) for i := 0; i < len(payments); i++ { - info, attempt, preimg, err := genInfo(t) - require.NoError(t, err, "unable to generate htlc message") + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) // Set the payment id accordingly in the payments slice. payments[i].id = info.PaymentIdentifier - attempt.AttemptID = attemptID + attempt, err := genAttemptWithHash( + t, attemptID, genSessionKey(t), rhash, + ) + require.NoError(t, err) + attemptID++ // Init the payment. @@ -148,7 +155,10 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { // Depending on the test case, fail or succeed the next // attempt. - attempt.AttemptID = attemptID + attempt, err = genAttemptWithHash( + t, attemptID, genSessionKey(t), rhash, + ) + require.NoError(t, err) attemptID++ _, err = p.RegisterAttempt(info.PaymentIdentifier, attempt) @@ -334,7 +344,7 @@ func assertDBPayments(t *testing.T, paymentDB DB, payments []*payment) { } // genPreimage generates a random preimage. -func genPreimage(t *testing.T) ([32]byte, error) { +func genPreimage(t *testing.T) (lntypes.Preimage, error) { t.Helper() var preimage [32]byte @@ -345,31 +355,75 @@ func genPreimage(t *testing.T) ([32]byte, error) { return preimage, nil } -// genInfo generates a payment creation info, an attempt info and a preimage. -func genInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo, - lntypes.Preimage, error) { +// genSessionKey generates a new random private key for use as a session key. +func genSessionKey(t *testing.T) *btcec.PrivateKey { + t.Helper() - preimage, err := genPreimage(t) - if err != nil { - return nil, nil, preimage, fmt.Errorf("unable to "+ - "generate preimage: %v", err) + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + + return key +} + +// genPaymentCreationInfo generates a payment creation info. +func genPaymentCreationInfo(t *testing.T, + paymentHash lntypes.Hash) *PaymentCreationInfo { + + t.Helper() + + return &PaymentCreationInfo{ + PaymentIdentifier: paymentHash, + Value: testRoute.ReceiverAmt(), + CreationTime: time.Unix(time.Now().Unix(), 0), + PaymentRequest: []byte("hola"), } +} + +// genPreimageAndHash generates a random preimage and its corresponding hash. +func genPreimageAndHash(t *testing.T) (lntypes.Preimage, lntypes.Hash, error) { + t.Helper() + + preimage, err := genPreimage(t) + require.NoError(t, err) rhash := sha256.Sum256(preimage[:]) var hash lntypes.Hash copy(hash[:], rhash[:]) + return preimage, hash, nil +} + +// genAttemptWithPreimage generates an HTLC attempt and returns both the +// attempt and preimage. +func genAttemptWithHash(t *testing.T, attemptID uint64, + sessionKey *btcec.PrivateKey, hash lntypes.Hash) (*HTLCAttemptInfo, + error) { + + t.Helper() + attempt, err := NewHtlcAttempt( - 0, priv, *testRoute.Copy(), time.Time{}, &hash, + attemptID, sessionKey, *testRoute.Copy(), time.Time{}, + &hash, ) - require.NoError(t, err) + if err != nil { + return nil, err + } - return &PaymentCreationInfo{ - PaymentIdentifier: rhash, - Value: testRoute.ReceiverAmt(), - CreationTime: time.Unix(time.Now().Unix(), 0), - PaymentRequest: []byte("hola"), - }, &attempt.HTLCAttemptInfo, preimage, nil + return &attempt.HTLCAttemptInfo, nil +} + +// genInfo generates a payment creation info and the corresponding preimage. +func genInfo(t *testing.T) (*PaymentCreationInfo, lntypes.Preimage, error) { + + preimage, _, err := genPreimageAndHash(t) + if err != nil { + return nil, preimage, err + } + + rhash := sha256.Sum256(preimage[:]) + creationInfo := genPaymentCreationInfo(t, rhash) + + return creationInfo, preimage, nil } // TestDeleteFailedAttempts checks that DeleteFailedAttempts properly removes @@ -481,7 +535,17 @@ func TestMPPRecordValidation(t *testing.T) { paymentDB := NewTestDB(t) - info, attempt, _, err := genInfo(t) + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + attemptID := uint64(0) + + attempt, err := genAttemptWithHash( + t, attemptID, genSessionKey(t), rhash, + ) require.NoError(t, err, "unable to generate htlc message") // Init the payment. @@ -502,29 +566,45 @@ func TestMPPRecordValidation(t *testing.T) { require.NoError(t, err, "unable to send htlc message") // Now try to register a non-MPP attempt, which should fail. - b := *attempt - b.AttemptID = 1 - b.Route.FinalHop().MPP = nil - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + attemptID++ + attempt2, err := genAttemptWithHash( + t, attemptID, genSessionKey(t), rhash, + ) + require.NoError(t, err) + + attempt2.Route.FinalHop().MPP = nil + + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) require.ErrorIs(t, err, ErrMPPayment) // Try to register attempt one with a different payment address. - b.Route.FinalHop().MPP = record.NewMPP( + attempt2.Route.FinalHop().MPP = record.NewMPP( info.Value, [32]byte{2}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) require.ErrorIs(t, err, ErrMPPPaymentAddrMismatch) // Try registering one with a different total amount. - b.Route.FinalHop().MPP = record.NewMPP( + attempt2.Route.FinalHop().MPP = record.NewMPP( info.Value/2, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) require.ErrorIs(t, err, ErrMPPTotalAmountMismatch) // Create and init a new payment. This time we'll check that we cannot // register an MPP attempt if we already registered a non-MPP one. - info, attempt, _, err = genInfo(t) + preimg, err = genPreimage(t) + require.NoError(t, err) + + rhash = sha256.Sum256(preimg[:]) + info = genPaymentCreationInfo(t, rhash) + + attemptID++ + attempt, err = genAttemptWithHash( + t, attemptID, genSessionKey(t), rhash, + ) + require.NoError(t, err) + require.NoError(t, err, "unable to generate htlc message") err = paymentDB.InitPayment(info.PaymentIdentifier, info) @@ -535,13 +615,17 @@ func TestMPPRecordValidation(t *testing.T) { require.NoError(t, err, "unable to send htlc message") // Attempt to register an MPP attempt, which should fail. - b = *attempt - b.AttemptID = 1 - b.Route.FinalHop().MPP = record.NewMPP( + attemptID++ + attempt2, err = genAttemptWithHash( + t, attemptID, genSessionKey(t), rhash, + ) + require.NoError(t, err) + + attempt2.Route.FinalHop().MPP = record.NewMPP( info.Value, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) require.ErrorIs(t, err, ErrNonMPPayment) } @@ -1495,8 +1579,11 @@ func TestSuccessesWithoutInFlight(t *testing.T) { paymentDB := NewTestDB(t) - info, _, preimg, err := genInfo(t) - require.NoError(t, err, "unable to generate htlc message") + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) // Attempt to complete the payment should fail. _, err = paymentDB.SettleAttempt( @@ -1515,8 +1602,11 @@ func TestFailsWithoutInFlight(t *testing.T) { paymentDB := NewTestDB(t) - info, _, _, err := genInfo(t) - require.NoError(t, err, "unable to generate htlc message") + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) // Calling Fail should return an error. _, err = paymentDB.Fail( @@ -1590,8 +1680,13 @@ func TestSwitchDoubleSend(t *testing.T) { paymentDB := NewTestDB(t) - info, attempt, preimg, err := genInfo(t) - require.NoError(t, err, "unable to generate htlc message") + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + attempt, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + require.NoError(t, err) // Sends base htlc message which initiate base status and move it to // StatusInFlight and verifies that it was changed. @@ -1663,8 +1758,13 @@ func TestSwitchFail(t *testing.T) { paymentDB := NewTestDB(t) - info, attempt, preimg, err := genInfo(t) - require.NoError(t, err, "unable to generate htlc message") + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + attempt, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + require.NoError(t, err) // Sends base htlc message which initiate StatusInFlight. err = paymentDB.InitPayment(info.PaymentIdentifier, info) @@ -1742,7 +1842,11 @@ func TestSwitchFail(t *testing.T) { assertPaymentInfo(t, paymentDB, info.PaymentIdentifier, info, nil, htlc) // Record another attempt. - attempt.AttemptID = 1 + attempt, err = genAttemptWithHash( + t, 1, genSessionKey(t), rhash, + ) + require.NoError(t, err) + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to send htlc message") assertDBPaymentstatus( @@ -1820,16 +1924,15 @@ func TestMultiShard(t *testing.T) { runSubTest := func(t *testing.T, test testCase) { paymentDB := NewTestDB(t) - info, attempt, preimg, err := genInfo(t) - if err != nil { - t.Fatalf("unable to generate htlc message: %v", err) - } + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) // Init the payment, moving it to the StatusInFlight state. err = paymentDB.InitPayment(info.PaymentIdentifier, info) - if err != nil { - t.Fatalf("unable to send htlc message: %v", err) - } + require.NoError(t, err) assertPaymentIndex(t, paymentDB, info.PaymentIdentifier) assertDBPaymentstatus( @@ -1844,19 +1947,23 @@ func TestMultiShard(t *testing.T) { // attempts's value to one third of the payment amount, and // populate the MPP options. shardAmt := info.Value / 3 - attempt.Route.FinalHop().AmtToForward = shardAmt - attempt.Route.FinalHop().MPP = record.NewMPP( - info.Value, [32]byte{1}, - ) var attempts []*HTLCAttemptInfo for i := uint64(0); i < 3; i++ { - a := *attempt - a.AttemptID = i - attempts = append(attempts, &a) + a, err := genAttemptWithHash( + t, i, genSessionKey(t), rhash, + ) + require.NoError(t, err) + + a.Route.FinalHop().AmtToForward = shardAmt + a.Route.FinalHop().MPP = record.NewMPP( + info.Value, [32]byte{1}, + ) + + attempts = append(attempts, a) _, err = paymentDB.RegisterAttempt( - info.PaymentIdentifier, &a, + info.PaymentIdentifier, a, ) if err != nil { t.Fatalf("unable to send htlc message: %v", err) @@ -1867,7 +1974,7 @@ func TestMultiShard(t *testing.T) { ) htlc := &htlcStatus{ - HTLCAttemptInfo: &a, + HTLCAttemptInfo: a, } assertPaymentInfo( t, paymentDB, info.PaymentIdentifier, info, nil, @@ -1878,9 +1985,17 @@ func TestMultiShard(t *testing.T) { // For a fourth attempt, check that attempting to // register it will fail since the total sent amount // will be too large. - b := *attempt - b.AttemptID = 3 - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + b, err := genAttemptWithHash( + t, 3, genSessionKey(t), rhash, + ) + require.NoError(t, err) + + b.Route.FinalHop().AmtToForward = shardAmt + b.Route.FinalHop().MPP = record.NewMPP( + info.Value, [32]byte{1}, + ) + + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) require.ErrorIs(t, err, ErrValueExceedsAmt) // Fail the second attempt. @@ -1977,9 +2092,17 @@ func TestMultiShard(t *testing.T) { // Try to register yet another attempt. This should fail now // that the payment has reached a terminal condition. - b = *attempt - b.AttemptID = 3 - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + b, err = genAttemptWithHash( + t, 3, genSessionKey(t), rhash, + ) + require.NoError(t, err) + + b.Route.FinalHop().AmtToForward = shardAmt + b.Route.FinalHop().MPP = record.NewMPP( + info.Value, [32]byte{1}, + ) + + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) if test.settleFirst { require.ErrorIs( t, err, ErrPaymentPendingSettled, @@ -2078,7 +2201,7 @@ func TestMultiShard(t *testing.T) { ) // Finally assert we cannot register more attempts. - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, &b) + _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) require.Equal(t, registerErr, err) } From de50f471e818aaa14f9dcc03894dd9e1ec038be8 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:09:54 +0200 Subject: [PATCH 38/78] paymentsdb: make QueryPayments test db agnostic We make the QueryPayments test db agnostic and also keep a small test for querying the duplicate payments case in the kv world. --- payments/db/kv_store_test.go | 207 ++------------- payments/db/payment_test.go | 475 +++++++++++++++++++++++++++++++++++ 2 files changed, 498 insertions(+), 184 deletions(-) diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index ccd2e450966..fbc8478d002 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -690,17 +690,19 @@ func putDuplicatePayment(t *testing.T, duplicateBucket kvdb.RwBucket, require.NoError(t, err) } -// TestQueryPayments tests retrieval of payments with forwards and reversed -// queries. -// -// TODO(ziggie): Make this test db agnostic. -func TestQueryPayments(t *testing.T) { - // Define table driven test for QueryPayments. +// TestKVStoreQueryPaymentsDuplicates tests the KV store's legacy duplicate +// payment handling. This tests the specific case where duplicate payments +// are stored in a nested bucket within the parent payment bucket. +func TestKVStoreQueryPaymentsDuplicates(t *testing.T) { + t.Parallel() + // Test payments have sequence indices [1, 3, 4, 5, 6, 7]. // Note that the payment with index 7 has the same payment hash as 6, // and is stored in a nested bucket within payment 6 rather than being - // its own entry in the payments bucket. We do this to test retrieval - // of legacy payments. + // its own entry in the payments bucket. This tests retrieval of legacy + // duplicate payments which is KV-store specific. + // These test cases focus on validating that duplicate payments (seq 7, + // nested under payment 6) are correctly returned in queries. tests := []struct { name string query Query @@ -712,31 +714,20 @@ func TestQueryPayments(t *testing.T) { expectedSeqNrs []uint64 }{ { - name: "IndexOffset at the end of the payments range", + name: "query includes duplicate payment in forward " + + "order", query: Query{ - IndexOffset: 7, - MaxPayments: 7, + IndexOffset: 5, + MaxPayments: 3, Reversed: false, IncludeIncomplete: true, }, - firstIndex: 0, - lastIndex: 0, - expectedSeqNrs: nil, - }, - { - name: "query in forwards order, start at beginning", - query: Query{ - IndexOffset: 0, - MaxPayments: 2, - Reversed: false, - IncludeIncomplete: true, - }, - firstIndex: 1, - lastIndex: 3, - expectedSeqNrs: []uint64{1, 3}, + firstIndex: 6, + lastIndex: 7, + expectedSeqNrs: []uint64{6, 7}, }, { - name: "query in forwards order, start at end, overflow", + name: "query duplicate payment at end", query: Query{ IndexOffset: 6, MaxPayments: 2, @@ -748,44 +739,7 @@ func TestQueryPayments(t *testing.T) { expectedSeqNrs: []uint64{7}, }, { - name: "start at offset index outside of payments", - query: Query{ - IndexOffset: 20, - MaxPayments: 2, - Reversed: false, - IncludeIncomplete: true, - }, - firstIndex: 0, - lastIndex: 0, - expectedSeqNrs: nil, - }, - { - name: "overflow in forwards order", - query: Query{ - IndexOffset: 4, - MaxPayments: math.MaxUint64, - Reversed: false, - IncludeIncomplete: true, - }, - firstIndex: 5, - lastIndex: 7, - expectedSeqNrs: []uint64{5, 6, 7}, - }, - { - name: "start at offset index outside of payments, " + - "reversed order", - query: Query{ - IndexOffset: 9, - MaxPayments: 2, - Reversed: true, - IncludeIncomplete: true, - }, - firstIndex: 6, - lastIndex: 7, - expectedSeqNrs: []uint64{6, 7}, - }, - { - name: "query in reverse order, start at end", + name: "query includes duplicate in reverse order", query: Query{ IndexOffset: 0, MaxPayments: 2, @@ -797,36 +751,11 @@ func TestQueryPayments(t *testing.T) { expectedSeqNrs: []uint64{6, 7}, }, { - name: "query in reverse order, starting in middle", - query: Query{ - IndexOffset: 4, - MaxPayments: 2, - Reversed: true, - IncludeIncomplete: true, - }, - firstIndex: 1, - lastIndex: 3, - expectedSeqNrs: []uint64{1, 3}, - }, - { - name: "query in reverse order, starting in middle, " + - "with underflow", - query: Query{ - IndexOffset: 4, - MaxPayments: 5, - Reversed: true, - IncludeIncomplete: true, - }, - firstIndex: 1, - lastIndex: 3, - expectedSeqNrs: []uint64{1, 3}, - }, - { - name: "all payments in reverse, order maintained", + name: "query all payments includes duplicate", query: Query{ IndexOffset: 0, - MaxPayments: 7, - Reversed: true, + MaxPayments: math.MaxUint64, + Reversed: false, IncludeIncomplete: true, }, firstIndex: 1, @@ -834,7 +763,7 @@ func TestQueryPayments(t *testing.T) { expectedSeqNrs: []uint64{1, 3, 4, 5, 6, 7}, }, { - name: "exclude incomplete payments", + name: "exclude incomplete includes duplicate", query: Query{ IndexOffset: 0, MaxPayments: 7, @@ -845,96 +774,6 @@ func TestQueryPayments(t *testing.T) { lastIndex: 7, expectedSeqNrs: []uint64{7}, }, - { - name: "query payments at index gap", - query: Query{ - IndexOffset: 1, - MaxPayments: 7, - Reversed: false, - IncludeIncomplete: true, - }, - firstIndex: 3, - lastIndex: 7, - expectedSeqNrs: []uint64{3, 4, 5, 6, 7}, - }, - { - name: "query payments reverse before index gap", - query: Query{ - IndexOffset: 3, - MaxPayments: 7, - Reversed: true, - IncludeIncomplete: true, - }, - firstIndex: 1, - lastIndex: 1, - expectedSeqNrs: []uint64{1}, - }, - { - name: "query payments reverse on index gap", - query: Query{ - IndexOffset: 2, - MaxPayments: 7, - Reversed: true, - IncludeIncomplete: true, - }, - firstIndex: 1, - lastIndex: 1, - expectedSeqNrs: []uint64{1}, - }, - { - name: "query payments forward on index gap", - query: Query{ - IndexOffset: 2, - MaxPayments: 2, - Reversed: false, - IncludeIncomplete: true, - }, - firstIndex: 3, - lastIndex: 4, - expectedSeqNrs: []uint64{3, 4}, - }, - { - name: "query in forwards order, with start creation " + - "time", - query: Query{ - IndexOffset: 0, - MaxPayments: 2, - Reversed: false, - IncludeIncomplete: true, - CreationDateStart: 5, - }, - firstIndex: 5, - lastIndex: 6, - expectedSeqNrs: []uint64{5, 6}, - }, - { - name: "query in forwards order, with start creation " + - "time at end, overflow", - query: Query{ - IndexOffset: 0, - MaxPayments: 2, - Reversed: false, - IncludeIncomplete: true, - CreationDateStart: 7, - }, - firstIndex: 7, - lastIndex: 7, - expectedSeqNrs: []uint64{7}, - }, - { - name: "query with start and end creation time", - query: Query{ - IndexOffset: 9, - MaxPayments: math.MaxUint64, - Reversed: true, - IncludeIncomplete: true, - CreationDateStart: 3, - CreationDateEnd: 5, - }, - firstIndex: 3, - lastIndex: 5, - expectedSeqNrs: []uint64{3, 4, 5}, - }, } for _, tt := range tests { diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 22ef30f4805..2c2d668aa00 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" "reflect" "testing" "time" @@ -2214,3 +2215,477 @@ func TestMultiShard(t *testing.T) { }) } } + +// TestQueryPayments tests retrieval of payments with forwards and reversed +// queries. +func TestQueryPayments(t *testing.T) { + // Define table driven test for QueryPayments. + // Test payments have sequence indices [1, 3, 4, 5, 6]. + // Note that payment with index 2 is deleted to create a gap in the + // sequence numbers. + tests := []struct { + name string + query Query + firstIndex uint64 + lastIndex uint64 + + // expectedSeqNrs contains the set of sequence numbers we expect + // our query to return. + expectedSeqNrs []uint64 + }{ + { + name: "IndexOffset at the end of the payments range", + query: Query{ + IndexOffset: 6, + MaxPayments: 7, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 0, + lastIndex: 0, + expectedSeqNrs: nil, + }, + { + name: "query in forwards order, start at beginning", + query: Query{ + IndexOffset: 0, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 1, + lastIndex: 3, + expectedSeqNrs: []uint64{1, 3}, + }, + { + name: "query in forwards order, start at end, overflow", + query: Query{ + IndexOffset: 5, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 6, + lastIndex: 6, + expectedSeqNrs: []uint64{6}, + }, + { + name: "start at offset index outside of payments", + query: Query{ + IndexOffset: 20, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 0, + lastIndex: 0, + expectedSeqNrs: nil, + }, + { + name: "overflow in forwards order", + query: Query{ + IndexOffset: 4, + MaxPayments: math.MaxUint64, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 5, + lastIndex: 6, + expectedSeqNrs: []uint64{5, 6}, + }, + { + name: "start at offset index outside of payments, " + + "reversed order", + query: Query{ + IndexOffset: 9, + MaxPayments: 2, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 5, + lastIndex: 6, + expectedSeqNrs: []uint64{5, 6}, + }, + { + name: "query in reverse order, start at end", + query: Query{ + IndexOffset: 0, + MaxPayments: 2, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 5, + lastIndex: 6, + expectedSeqNrs: []uint64{5, 6}, + }, + { + name: "query in reverse order, starting in middle", + query: Query{ + IndexOffset: 4, + MaxPayments: 2, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 1, + lastIndex: 3, + expectedSeqNrs: []uint64{1, 3}, + }, + { + name: "query in reverse order, starting in middle, " + + "with underflow", + query: Query{ + IndexOffset: 4, + MaxPayments: 5, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 1, + lastIndex: 3, + expectedSeqNrs: []uint64{1, 3}, + }, + { + name: "all payments in reverse, order maintained", + query: Query{ + IndexOffset: 0, + MaxPayments: 7, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 1, + lastIndex: 6, + expectedSeqNrs: []uint64{1, 3, 4, 5, 6}, + }, + { + name: "exclude incomplete payments", + query: Query{ + IndexOffset: 0, + MaxPayments: 7, + Reversed: false, + IncludeIncomplete: false, + }, + firstIndex: 6, + lastIndex: 6, + expectedSeqNrs: []uint64{6}, + }, + { + name: "query payments at index gap", + query: Query{ + IndexOffset: 1, + MaxPayments: 7, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 3, + lastIndex: 6, + expectedSeqNrs: []uint64{3, 4, 5, 6}, + }, + { + name: "query payments reverse before index gap", + query: Query{ + IndexOffset: 3, + MaxPayments: 7, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 1, + lastIndex: 1, + expectedSeqNrs: []uint64{1}, + }, + { + name: "query payments reverse on index gap", + query: Query{ + IndexOffset: 2, + MaxPayments: 7, + Reversed: true, + IncludeIncomplete: true, + }, + firstIndex: 1, + lastIndex: 1, + expectedSeqNrs: []uint64{1}, + }, + { + name: "query payments forward on index gap", + query: Query{ + IndexOffset: 2, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + }, + firstIndex: 3, + lastIndex: 4, + expectedSeqNrs: []uint64{3, 4}, + }, + { + name: "query in forwards order, with start creation " + + "time", + query: Query{ + IndexOffset: 0, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + CreationDateStart: 5, + }, + firstIndex: 5, + lastIndex: 6, + expectedSeqNrs: []uint64{5, 6}, + }, + { + name: "query in forwards order, with start creation " + + "time at end, overflow", + query: Query{ + IndexOffset: 0, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + CreationDateStart: 6, + }, + firstIndex: 6, + lastIndex: 6, + expectedSeqNrs: []uint64{6}, + }, + { + name: "query with start and end creation time", + query: Query{ + IndexOffset: 9, + MaxPayments: math.MaxUint64, + Reversed: true, + IncludeIncomplete: true, + CreationDateStart: 3, + CreationDateEnd: 5, + }, + firstIndex: 3, + lastIndex: 5, + expectedSeqNrs: []uint64{3, 4, 5}, + }, + { + name: "query with only end creation time", + query: Query{ + IndexOffset: 0, + MaxPayments: math.MaxUint64, + Reversed: false, + IncludeIncomplete: true, + CreationDateEnd: 4, + }, + firstIndex: 1, + lastIndex: 4, + expectedSeqNrs: []uint64{1, 3, 4}, + }, + { + name: "query reversed with creation date start", + query: Query{ + IndexOffset: 0, + MaxPayments: 3, + Reversed: true, + IncludeIncomplete: true, + CreationDateStart: 3, + }, + firstIndex: 4, + lastIndex: 6, + expectedSeqNrs: []uint64{4, 5, 6}, + }, + { + name: "count total with forward pagination", + query: Query{ + IndexOffset: 0, + MaxPayments: 2, + Reversed: false, + IncludeIncomplete: true, + CountTotal: true, + }, + firstIndex: 1, + lastIndex: 3, + expectedSeqNrs: []uint64{1, 3}, + }, + { + name: "count total with reverse pagination", + query: Query{ + IndexOffset: 0, + MaxPayments: 2, + Reversed: true, + IncludeIncomplete: true, + CountTotal: true, + }, + firstIndex: 5, + lastIndex: 6, + expectedSeqNrs: []uint64{5, 6}, + }, + { + name: "count total with filters", + query: Query{ + IndexOffset: 0, + MaxPayments: math.MaxUint64, + Reversed: false, + IncludeIncomplete: false, + CountTotal: true, + }, + firstIndex: 6, + lastIndex: 6, + expectedSeqNrs: []uint64{6}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB := NewTestDB(t) + + // Make a preliminary query to make sure it's ok to + // query when we have no payments. + resp, err := paymentDB.QueryPayments(ctx, tt.query) + require.NoError(t, err) + require.Len(t, resp.Payments, 0) + + // Populate the database with a set of test payments. + // We create 6 payments, deleting the payment at index + // 2 so that we cover the case where sequence numbers + // are missing. + numberOfPayments := 6 + + // Store payment info for all payments so we can delete + // one after all are created. + var paymentInfos []*PaymentCreationInfo + + // First, create all payments. + for i := range numberOfPayments { + // Generate a test payment. + info, _, err := genInfo(t) + require.NoError(t, err) + + // Override creation time to allow for testing + // of CreationDateStart and CreationDateEnd. + info.CreationTime = time.Unix(int64(i+1), 0) + + paymentInfos = append(paymentInfos, info) + + // Create a new payment entry in the database. + err = paymentDB.InitPayment( + info.PaymentIdentifier, info, + ) + require.NoError(t, err) + } + + // Now delete the payment at index 1 (the second + // payment). + pmt, err := paymentDB.FetchPayment( + paymentInfos[1].PaymentIdentifier, + ) + require.NoError(t, err) + + // We delete the whole payment. + err = paymentDB.DeletePayment( + paymentInfos[1].PaymentIdentifier, false, + ) + require.NoError(t, err) + + // Verify the payment is deleted. + _, err = paymentDB.FetchPayment( + paymentInfos[1].PaymentIdentifier, + ) + require.ErrorIs( + t, err, ErrPaymentNotInitiated, + ) + + // Verify the index is removed (KV store only). + assertNoIndex( + t, paymentDB, pmt.SequenceNum, + ) + + // For the last payment, settle it so we have at least + // one completed payment for the "exclude incomplete" + // test case. + lastPaymentInfo := paymentInfos[numberOfPayments-1] + attempt, err := NewHtlcAttempt( + 1, priv, testRoute, + time.Unix(100, 0), + &lastPaymentInfo.PaymentIdentifier, + ) + require.NoError(t, err) + + _, err = paymentDB.RegisterAttempt( + lastPaymentInfo.PaymentIdentifier, + &attempt.HTLCAttemptInfo, + ) + require.NoError(t, err) + + var preimg lntypes.Preimage + copy(preimg[:], rev[:]) + + _, err = paymentDB.SettleAttempt( + lastPaymentInfo.PaymentIdentifier, + attempt.AttemptID, + &HTLCSettleInfo{ + Preimage: preimg, + }, + ) + require.NoError(t, err) + + // Fetch all payments in the database. + resp, err = paymentDB.QueryPayments( + ctx, Query{ + IndexOffset: 0, + MaxPayments: math.MaxUint64, + IncludeIncomplete: true, + }, + ) + require.NoError(t, err) + + allPayments := resp.Payments + + if len(allPayments) != 5 { + t.Fatalf("Number of payments received does "+ + "not match expected one. Got %v, "+ + "want %v.", len(allPayments), 5) + } + + querySlice, err := paymentDB.QueryPayments( + ctx, tt.query, + ) + require.NoError(t, err) + + if tt.firstIndex != querySlice.FirstIndexOffset || + tt.lastIndex != querySlice.LastIndexOffset { + + t.Errorf("First or last index does not match "+ + "expected index. Want (%d, %d), "+ + "got (%d, %d).", + tt.firstIndex, tt.lastIndex, + querySlice.FirstIndexOffset, + querySlice.LastIndexOffset) + } + + if len(querySlice.Payments) != len(tt.expectedSeqNrs) { + t.Errorf("expected: %v payments, got: %v", + len(tt.expectedSeqNrs), + len(querySlice.Payments)) + } + + for i, seqNr := range tt.expectedSeqNrs { + q := querySlice.Payments[i] + if seqNr != q.SequenceNum { + t.Errorf("sequence numbers do not "+ + "match, got %v, want %v", + q.SequenceNum, seqNr) + } + } + + // Verify CountTotal is set correctly when requested. + if tt.query.CountTotal { + // We should have 5 total payments + // (6 created - 1 deleted). + expectedTotal := uint64(5) + require.Equal( + t, expectedTotal, querySlice.TotalCount, + "expected total count %v, got %v", + expectedTotal, querySlice.TotalCount) + } else { + require.Equal( + t, uint64(0), querySlice.TotalCount, + "expected total count 0 when "+ + "CountTotal=false") + } + }) + } +} From 2eadbfb54df992deecfc5bd9fbc1863300c46713 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 18:59:26 +0200 Subject: [PATCH 39/78] paymentsdb: fix test case before testing sql backend We are now not supporting the LegacyPayload for the onion packet anymore. All payments and their onion payload need to be tlv encoded. The sql backend assumes tlv so we have to always set the in memory presentation of a hop where the legacy parameter is still available but deprecated to false, otherwise the hops will not be equal and unit tests for the sql backend will fail when switched on in the next commits. --- payments/db/payment_test.go | 7 +++++-- routing/route/route.go | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 2c2d668aa00..df922a455c0 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -59,7 +59,10 @@ var ( ChannelID: 12345, OutgoingTimeLock: 111, AmtToForward: 555, - LegacyPayload: true, + + // Only tlv payloads are now supported in LND therefore we set + // LegacyPayload to false. + LegacyPayload: false, } testRoute = route.Route{ @@ -2203,7 +2206,7 @@ func TestMultiShard(t *testing.T) { // Finally assert we cannot register more attempts. _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) - require.Equal(t, registerErr, err) + require.ErrorIs(t, err, registerErr) } for _, test := range tests { diff --git a/routing/route/route.go b/routing/route/route.go index 1bb52badbdb..a575c415bd7 100644 --- a/routing/route/route.go +++ b/routing/route/route.go @@ -164,6 +164,9 @@ type Hop struct { // The only reason we are keeping this member is that it could be the // case that we have serialised hops persisted to disk where // LegacyPayload is true. + // + // TODO(ziggie): Remove this field once we phase out the kv backend + // for payments. LegacyPayload bool // Metadata is additional data that is sent along with the payment to From 570863ca269cb7588a337d6fb1631d87f02cd215 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 17:26:45 +0200 Subject: [PATCH 40/78] paymentsdb: add harness to run payment db agnostic tests In commit adds the harness which will be used to run db agnostic tests against the kv and sql backend. We have adopted all the unit tests so far so that with this commit all the payment tests not specifically put into the kv_store_test.go should all pass for all backends. --- payments/db/test_kvdb.go | 2 + payments/db/test_postgres.go | 77 ++++++++++++++++++++++++++++++++++++ payments/db/test_sqlite.go | 56 ++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 payments/db/test_postgres.go create mode 100644 payments/db/test_sqlite.go diff --git a/payments/db/test_kvdb.go b/payments/db/test_kvdb.go index e0ee1738d7a..a4bbfccbd97 100644 --- a/payments/db/test_kvdb.go +++ b/payments/db/test_kvdb.go @@ -1,3 +1,5 @@ +//go:build !test_db_sqlite && !test_db_postgres + package paymentsdb import ( diff --git a/payments/db/test_postgres.go b/payments/db/test_postgres.go new file mode 100644 index 00000000000..b4f00f9b015 --- /dev/null +++ b/payments/db/test_postgres.go @@ -0,0 +1,77 @@ +//go:build test_db_postgres && !test_db_sqlite + +package paymentsdb + +import ( + "database/sql" + "testing" + + "github.com/lightningnetwork/lnd/sqldb" + "github.com/stretchr/testify/require" +) + +// NewTestDB is a helper function that creates a SQLStore backed by a SQL +// database for testing. +func NewTestDB(t testing.TB, opts ...OptionModifier) DB { + return NewTestDBWithFixture(t, nil, opts...) +} + +// NewTestDBFixture creates a new sqldb.TestPgFixture for testing purposes. +func NewTestDBFixture(t *testing.T) *sqldb.TestPgFixture { + pgFixture := sqldb.NewTestPgFixture( + t, sqldb.DefaultPostgresFixtureLifetime, + ) + t.Cleanup(func() { + pgFixture.TearDown(t) + }) + return pgFixture +} + +// NewTestDBWithFixture is a helper function that creates a SQLStore backed by a +// SQL database for testing. +func NewTestDBWithFixture(t testing.TB, + pgFixture *sqldb.TestPgFixture, opts ...OptionModifier) DB { + + var querier BatchedSQLQueries + if pgFixture == nil { + querier = newBatchQuerier(t) + } else { + querier = newBatchQuerierWithFixture(t, pgFixture) + } + + store, err := NewSQLStore( + &SQLStoreConfig{ + QueryCfg: sqldb.DefaultPostgresConfig(), + }, querier, opts..., + ) + require.NoError(t, err) + + return store +} + +// newBatchQuerier creates a new BatchedSQLQueries instance for testing +// using a PostgreSQL database fixture. +func newBatchQuerier(t testing.TB) BatchedSQLQueries { + pgFixture := sqldb.NewTestPgFixture( + t, sqldb.DefaultPostgresFixtureLifetime, + ) + t.Cleanup(func() { + pgFixture.TearDown(t) + }) + + return newBatchQuerierWithFixture(t, pgFixture) +} + +// newBatchQuerierWithFixture creates a new BatchedSQLQueries instance for +// testing using a PostgreSQL database fixture. +func newBatchQuerierWithFixture(t testing.TB, + pgFixture *sqldb.TestPgFixture) BatchedSQLQueries { + + db := sqldb.NewTestPostgresDB(t, pgFixture).BaseDB + + return sqldb.NewTransactionExecutor( + db, func(tx *sql.Tx) SQLQueries { + return db.WithTx(tx) + }, + ) +} diff --git a/payments/db/test_sqlite.go b/payments/db/test_sqlite.go new file mode 100644 index 00000000000..8664db48409 --- /dev/null +++ b/payments/db/test_sqlite.go @@ -0,0 +1,56 @@ +//go:build !test_db_postgres && test_db_sqlite + +package paymentsdb + +import ( + "database/sql" + "testing" + + "github.com/lightningnetwork/lnd/sqldb" + "github.com/stretchr/testify/require" +) + +// NewTestDB is a helper function that creates a SQLStore backed by a SQL +// database for testing. +func NewTestDB(t testing.TB, opts ...OptionModifier) DB { + return NewTestDBWithFixture(t, nil, opts...) +} + +// NewTestDBFixture is a no-op for the sqlite build. +func NewTestDBFixture(_ *testing.T) *sqldb.TestPgFixture { + return nil +} + +// NewTestDBWithFixture is a helper function that creates a SQLStore backed by a +// SQL database for testing. +func NewTestDBWithFixture(t testing.TB, _ *sqldb.TestPgFixture, + opts ...OptionModifier) DB { + + store, err := NewSQLStore( + &SQLStoreConfig{ + QueryCfg: sqldb.DefaultSQLiteConfig(), + }, newBatchQuerier(t), opts..., + ) + require.NoError(t, err) + return store +} + +// newBatchQuerier creates a new BatchedSQLQueries instance for testing +// using a SQLite database. +func newBatchQuerier(t testing.TB) BatchedSQLQueries { + return newBatchQuerierWithFixture(t, nil) +} + +// newBatchQuerierWithFixture creates a new BatchedSQLQueries instance for +// testing using a SQLite database. +func newBatchQuerierWithFixture(t testing.TB, + _ *sqldb.TestPgFixture) BatchedSQLQueries { + + db := sqldb.NewTestSqliteDB(t).BaseDB + + return sqldb.NewTransactionExecutor( + db, func(tx *sql.Tx) SQLQueries { + return db.WithTx(tx) + }, + ) +} From 080fd78fb4a8b0fcaade2f16bc50d9aad6777138 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 14 Nov 2025 18:20:59 +0100 Subject: [PATCH 41/78] paymentsdb: introduce a harness interface The design of the sql and kv db are a bit different. A harness interface is introduced which allows us to unit most of the test and keep the backend specific tests at a minimum. --- payments/db/kv_store_test.go | 78 ------------------------------------ payments/db/payment_test.go | 42 +++++++++++-------- payments/db/test_harness.go | 26 ++++++++++++ payments/db/test_kvdb.go | 73 ++++++++++++++++++++++++++++++++- payments/db/test_postgres.go | 22 +++++++++- payments/db/test_sqlite.go | 22 +++++++++- 6 files changed, 162 insertions(+), 101 deletions(-) create mode 100644 payments/db/test_harness.go diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index fbc8478d002..ee28e12e0d6 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -18,7 +18,6 @@ import ( "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/tlv" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -252,83 +251,6 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { require.Equal(t, 1, indexCount) } -type htlcStatus struct { - *HTLCAttemptInfo - settle *lntypes.Preimage - failure *HTLCFailReason -} - -// fetchPaymentIndexEntry gets the payment hash for the sequence number provided -// from our payment indexes bucket. -func fetchPaymentIndexEntry(t *testing.T, p *KVStore, - sequenceNumber uint64) (*lntypes.Hash, error) { - - t.Helper() - - var hash lntypes.Hash - - if err := kvdb.View(p.db, func(tx walletdb.ReadTx) error { - indexBucket := tx.ReadBucket(paymentsIndexBucket) - key := make([]byte, 8) - byteOrder.PutUint64(key, sequenceNumber) - - indexValue := indexBucket.Get(key) - if indexValue == nil { - return ErrNoSequenceNrIndex - } - - r := bytes.NewReader(indexValue) - - var err error - hash, err = deserializePaymentIndex(r) - - return err - }, func() { - hash = lntypes.Hash{} - }); err != nil { - return nil, err - } - - return &hash, nil -} - -// assertPaymentIndex looks up the index for a payment in the db and checks -// that its payment hash matches the expected hash passed in. -func assertPaymentIndex(t *testing.T, p DB, expectedHash lntypes.Hash) { - t.Helper() - - // Only the kv implementation uses the index so we exit early if the - // payment db is not a kv implementation. This helps us to reuse the - // same test for both implementations. - kvPaymentDB, ok := p.(*KVStore) - if !ok { - return - } - - // Lookup the payment so that we have its sequence number and check - // that is has correctly been indexed in the payment indexes bucket. - pmt, err := kvPaymentDB.FetchPayment(expectedHash) - require.NoError(t, err) - - hash, err := fetchPaymentIndexEntry(t, kvPaymentDB, pmt.SequenceNum) - require.NoError(t, err) - assert.Equal(t, expectedHash, *hash) -} - -// assertNoIndex checks that an index for the sequence number provided does not -// exist. -func assertNoIndex(t *testing.T, p DB, seqNr uint64) { - t.Helper() - - kvPaymentDB, ok := p.(*KVStore) - if !ok { - return - } - - _, err := fetchPaymentIndexEntry(t, kvPaymentDB, seqNr) - require.Equal(t, ErrNoSequenceNrIndex, err) -} - func makeFakeInfo(t *testing.T) (*PaymentCreationInfo, *HTLCAttemptInfo) { diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index df922a455c0..aa42b4ecaf2 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -103,6 +103,14 @@ var ( } ) +// htlcStatus is a helper structure used in tests to track the status of an HTLC +// attempt, including whether it was settled or failed. +type htlcStatus struct { + *HTLCAttemptInfo + settle *lntypes.Preimage + failure *HTLCFailReason +} + // payment is a helper structure that holds basic information on a test payment, // such as the payment id, the status and the total number of HTLCs attempted. type payment struct { @@ -446,7 +454,7 @@ func TestDeleteFailedAttempts(t *testing.T) { // testDeleteFailedAttempts tests the DeleteFailedAttempts method with the // given keepFailedPaymentAttempts flag as argument. func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { - paymentDB := NewTestDB( + paymentDB, _ := NewTestDB( t, WithKeepFailedPaymentAttempts(keepFailedPaymentAttempts), ) @@ -537,7 +545,7 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { func TestMPPRecordValidation(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, _ := NewTestDB(t) preimg, err := genPreimage(t) require.NoError(t, err) @@ -638,7 +646,7 @@ func TestMPPRecordValidation(t *testing.T) { func TestDeleteSinglePayment(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, _ := NewTestDB(t) // Register four payments: // All payments will have one failed HTLC attempt and one HTLC attempt @@ -1581,7 +1589,7 @@ func TestEmptyRoutesGenerateSphinxPacket(t *testing.T) { func TestSuccessesWithoutInFlight(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, _ := NewTestDB(t) preimg, err := genPreimage(t) require.NoError(t, err) @@ -1604,7 +1612,7 @@ func TestSuccessesWithoutInFlight(t *testing.T) { func TestFailsWithoutInFlight(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, _ := NewTestDB(t) preimg, err := genPreimage(t) require.NoError(t, err) @@ -1624,7 +1632,7 @@ func TestFailsWithoutInFlight(t *testing.T) { func TestDeletePayments(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, _ := NewTestDB(t) // Register three payments: // 1. A payment with two failed attempts. @@ -1682,7 +1690,7 @@ func TestDeletePayments(t *testing.T) { func TestSwitchDoubleSend(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, harness := NewTestDB(t) preimg, err := genPreimage(t) require.NoError(t, err) @@ -1697,7 +1705,7 @@ func TestSwitchDoubleSend(t *testing.T) { err = paymentDB.InitPayment(info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") - assertPaymentIndex(t, paymentDB, info.PaymentIdentifier) + harness.AssertPaymentIndex(t, info.PaymentIdentifier) assertDBPaymentstatus( t, paymentDB, info.PaymentIdentifier, StatusInitiated, ) @@ -1760,7 +1768,7 @@ func TestSwitchDoubleSend(t *testing.T) { func TestSwitchFail(t *testing.T) { t.Parallel() - paymentDB := NewTestDB(t) + paymentDB, harness := NewTestDB(t) preimg, err := genPreimage(t) require.NoError(t, err) @@ -1774,7 +1782,7 @@ func TestSwitchFail(t *testing.T) { err = paymentDB.InitPayment(info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") - assertPaymentIndex(t, paymentDB, info.PaymentIdentifier) + harness.AssertPaymentIndex(t, info.PaymentIdentifier) assertDBPaymentstatus( t, paymentDB, info.PaymentIdentifier, StatusInitiated, ) @@ -1808,8 +1816,8 @@ func TestSwitchFail(t *testing.T) { // Check that our index has been updated, and the old index has been // removed. - assertPaymentIndex(t, paymentDB, info.PaymentIdentifier) - assertNoIndex(t, paymentDB, payment.SequenceNum) + harness.AssertPaymentIndex(t, info.PaymentIdentifier) + harness.AssertNoIndex(t, payment.SequenceNum) assertDBPaymentstatus( t, paymentDB, info.PaymentIdentifier, StatusInitiated, @@ -1926,7 +1934,7 @@ func TestMultiShard(t *testing.T) { } runSubTest := func(t *testing.T, test testCase) { - paymentDB := NewTestDB(t) + paymentDB, harness := NewTestDB(t) preimg, err := genPreimage(t) require.NoError(t, err) @@ -1938,7 +1946,7 @@ func TestMultiShard(t *testing.T) { err = paymentDB.InitPayment(info.PaymentIdentifier, info) require.NoError(t, err) - assertPaymentIndex(t, paymentDB, info.PaymentIdentifier) + harness.AssertPaymentIndex(t, info.PaymentIdentifier) assertDBPaymentstatus( t, paymentDB, info.PaymentIdentifier, StatusInitiated, ) @@ -2533,7 +2541,7 @@ func TestQueryPayments(t *testing.T) { ctx := t.Context() - paymentDB := NewTestDB(t) + paymentDB, harness := NewTestDB(t) // Make a preliminary query to make sure it's ok to // query when we have no payments. @@ -2592,8 +2600,8 @@ func TestQueryPayments(t *testing.T) { ) // Verify the index is removed (KV store only). - assertNoIndex( - t, paymentDB, pmt.SequenceNum, + harness.AssertNoIndex( + t, pmt.SequenceNum, ) // For the last payment, settle it so we have at least diff --git a/payments/db/test_harness.go b/payments/db/test_harness.go new file mode 100644 index 00000000000..11f88c3f833 --- /dev/null +++ b/payments/db/test_harness.go @@ -0,0 +1,26 @@ +package paymentsdb + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lntypes" +) + +// TestHarness provides implementation-specific test utilities for the payments +// database. Different database backends (KV, SQL) have different internal +// structures and indexing mechanisms, so this interface allows tests to verify +// implementation-specific behavior without coupling the test logic to a +// particular backend. +type TestHarness interface { + // AssertPaymentIndex checks that a payment is correctly indexed. + // For KV: verifies the payment index bucket entry exists and points + // to the correct payment hash. + // For SQL: no-op (SQL doesn't use a separate index bucket). + AssertPaymentIndex(t *testing.T, expectedHash lntypes.Hash) + + // AssertNoIndex checks that an index for a sequence number doesn't + // exist. + // For KV: verifies the index bucket entry is deleted. + // For SQL: no-op. + AssertNoIndex(t *testing.T, seqNr uint64) +} diff --git a/payments/db/test_kvdb.go b/payments/db/test_kvdb.go index a4bbfccbd97..ed1710b14fa 100644 --- a/payments/db/test_kvdb.go +++ b/payments/db/test_kvdb.go @@ -3,14 +3,18 @@ package paymentsdb import ( + "bytes" "testing" + "github.com/btcsuite/btcwallet/walletdb" "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // NewTestDB is a helper function that creates an BBolt database for testing. -func NewTestDB(t *testing.T, opts ...OptionModifier) DB { +func NewTestDB(t *testing.T, opts ...OptionModifier) (DB, TestHarness) { backend, backendCleanup, err := kvdb.GetTestBackend( t.TempDir(), "paymentsDB", ) @@ -21,7 +25,7 @@ func NewTestDB(t *testing.T, opts ...OptionModifier) DB { paymentDB, err := NewKVStore(backend, opts...) require.NoError(t, err) - return paymentDB + return paymentDB, &kvTestHarness{db: paymentDB} } // NewKVTestDB is a helper function that creates an BBolt database for testing @@ -40,3 +44,68 @@ func NewKVTestDB(t *testing.T, opts ...OptionModifier) *KVStore { return paymentDB } + +// kvTestHarness is the KV-specific test harness implementation. +type kvTestHarness struct { + db *KVStore +} + +// AssertPaymentIndex looks up the index for a payment in the db and checks +// that its payment hash matches the expected hash passed in. +func (h *kvTestHarness) AssertPaymentIndex(t *testing.T, + expectedHash lntypes.Hash) { + + t.Helper() + + // Lookup the payment so that we have its sequence number and check + // that it has correctly been indexed in the payment indexes bucket. + pmt, err := h.db.FetchPayment(expectedHash) + require.NoError(t, err) + + hash, err := h.fetchPaymentIndexEntry(t, pmt.SequenceNum) + require.NoError(t, err) + assert.Equal(t, expectedHash, *hash) +} + +// AssertNoIndex checks that an index for the sequence number provided does not +// exist. +func (h *kvTestHarness) AssertNoIndex(t *testing.T, seqNr uint64) { + t.Helper() + + _, err := h.fetchPaymentIndexEntry(t, seqNr) + require.Equal(t, ErrNoSequenceNrIndex, err) +} + +// fetchPaymentIndexEntry gets the payment hash for the sequence number +// provided from the payment indexes bucket. +func (h *kvTestHarness) fetchPaymentIndexEntry(t *testing.T, + sequenceNumber uint64) (*lntypes.Hash, error) { + + t.Helper() + + var hash lntypes.Hash + + if err := kvdb.View(h.db.db, func(tx walletdb.ReadTx) error { + indexBucket := tx.ReadBucket(paymentsIndexBucket) + key := make([]byte, 8) + byteOrder.PutUint64(key, sequenceNumber) + + indexValue := indexBucket.Get(key) + if indexValue == nil { + return ErrNoSequenceNrIndex + } + + r := bytes.NewReader(indexValue) + + var err error + hash, err = deserializePaymentIndex(r) + + return err + }, func() { + hash = lntypes.Hash{} + }); err != nil { + return nil, err + } + + return &hash, nil +} diff --git a/payments/db/test_postgres.go b/payments/db/test_postgres.go index b4f00f9b015..bd22703f1ff 100644 --- a/payments/db/test_postgres.go +++ b/payments/db/test_postgres.go @@ -6,14 +6,16 @@ import ( "database/sql" "testing" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/sqldb" "github.com/stretchr/testify/require" ) // NewTestDB is a helper function that creates a SQLStore backed by a SQL // database for testing. -func NewTestDB(t testing.TB, opts ...OptionModifier) DB { - return NewTestDBWithFixture(t, nil, opts...) +func NewTestDB(t testing.TB, opts ...OptionModifier) (DB, TestHarness) { + db := NewTestDBWithFixture(t, nil, opts...) + return db, &noopTestHarness{} } // NewTestDBFixture creates a new sqldb.TestPgFixture for testing purposes. @@ -75,3 +77,19 @@ func newBatchQuerierWithFixture(t testing.TB, }, ) } + +// noopTestHarness is the SQL test harness implementation. Since SQL doesn't +// use a separate payment index bucket like KV, these assertions are no-ops. +type noopTestHarness struct{} + +// AssertPaymentIndex is a no-op for SQL implementations. +func (h *noopTestHarness) AssertPaymentIndex(t *testing.T, + expectedHash lntypes.Hash) { + + // No-op: SQL doesn't use a separate index bucket. +} + +// AssertNoIndex is a no-op for SQL implementations. +func (h *noopTestHarness) AssertNoIndex(t *testing.T, seqNr uint64) { + // No-op: SQL doesn't use a separate index bucket. +} diff --git a/payments/db/test_sqlite.go b/payments/db/test_sqlite.go index 8664db48409..99d10478051 100644 --- a/payments/db/test_sqlite.go +++ b/payments/db/test_sqlite.go @@ -6,14 +6,16 @@ import ( "database/sql" "testing" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/sqldb" "github.com/stretchr/testify/require" ) // NewTestDB is a helper function that creates a SQLStore backed by a SQL // database for testing. -func NewTestDB(t testing.TB, opts ...OptionModifier) DB { - return NewTestDBWithFixture(t, nil, opts...) +func NewTestDB(t testing.TB, opts ...OptionModifier) (DB, TestHarness) { + db := NewTestDBWithFixture(t, nil, opts...) + return db, &noopTestHarness{} } // NewTestDBFixture is a no-op for the sqlite build. @@ -54,3 +56,19 @@ func newBatchQuerierWithFixture(t testing.TB, }, ) } + +// noopTestHarness is the SQL test harness implementation. Since SQL doesn't +// use a separate payment index bucket like KV, these assertions are no-ops. +type noopTestHarness struct{} + +// AssertPaymentIndex is a no-op for SQL implementations. +func (h *noopTestHarness) AssertPaymentIndex(t *testing.T, + expectedHash lntypes.Hash) { + + // No-op: SQL doesn't use a separate index bucket. +} + +// AssertNoIndex is a no-op for SQL implementations. +func (h *noopTestHarness) AssertNoIndex(t *testing.T, seqNr uint64) { + // No-op: SQL doesn't use a separate index bucket. +} From 5f1f41359664e2ecd75a183c971ddc7446b8d266 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 14 Nov 2025 14:15:50 +0100 Subject: [PATCH 42/78] paymentsdb: make specific kv store tests only available via build tag --- payments/db/kv_store_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index ee28e12e0d6..f0c2b148fd0 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -1,3 +1,5 @@ +//go:build !test_db_sqlite && !test_db_postgres + package paymentsdb import ( From f518931cf0ca62d9e6daf66e78cbfa6735bcfc83 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 16 Oct 2025 01:10:28 +0200 Subject: [PATCH 43/78] itest: fix list_payments accuracy edge case --- itest/lnd_payment_test.go | 59 ++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/itest/lnd_payment_test.go b/itest/lnd_payment_test.go index 37aff052264..f683cd44a80 100644 --- a/itest/lnd_payment_test.go +++ b/itest/lnd_payment_test.go @@ -504,61 +504,86 @@ func testListPayments(ht *lntest.HarnessTest) { expected bool } - // Create test cases to check the timestamp filters. - createCases := func(createTimeSeconds uint64) []testCase { + // Create test cases with proper rounding for start and end dates. + createCases := func(startTimeSeconds, + endTimeSeconds uint64) []testCase { + return []testCase{ { // Use a start date same as the creation date - // should return us the item. + // (truncated) should return us the item. name: "exact start date", - startDate: createTimeSeconds, + startDate: startTimeSeconds, expected: true, }, { // Use an earlier start date should return us // the item. name: "earlier start date", - startDate: createTimeSeconds - 1, + startDate: startTimeSeconds - 1, expected: true, }, { // Use a future start date should return us // nothing. name: "future start date", - startDate: createTimeSeconds + 1, + startDate: startTimeSeconds + 1, expected: false, }, { // Use an end date same as the creation date - // should return us the item. + // (ceiling) should return us the item. name: "exact end date", - endDate: createTimeSeconds, + endDate: endTimeSeconds, expected: true, }, { // Use an end date in the future should return // us the item. name: "future end date", - endDate: createTimeSeconds + 1, + endDate: endTimeSeconds + 1, expected: true, }, { // Use an earlier end date should return us // nothing. - name: "earlier end date", - endDate: createTimeSeconds - 1, + name: "earlier end date", + // The native sql backend has a higher + // precision than the kv backend, the native sql + // backend uses microseconds, the kv backend + // when filtering uses seconds so we need to + // subtract 2 seconds to ensure the payment is + // not included. + // We could also truncate before inserting + // into the sql db but I rather relax this test + // here. + endDate: endTimeSeconds - 2, expected: false, }, } } - // Get the payment creation time in seconds. - paymentCreateSeconds := uint64( - p.CreationTimeNs / time.Second.Nanoseconds(), + // Get the payment creation time in seconds, using different approaches + // for start and end date comparisons to avoid rounding issues. + creationTime := time.Unix(0, p.CreationTimeNs) + + // For start date comparisons: use truncation (floor) to include + // payments from the beginning of that second. + paymentCreateSecondsStart := uint64( + creationTime.Truncate(time.Second).Unix(), + ) + + // For end date comparisons: use ceiling to include payments up to the + // end of that second. + paymentCreateSecondsEnd := uint64( + (p.CreationTimeNs + time.Second.Nanoseconds() - 1) / + time.Second.Nanoseconds(), ) // Create test cases from the payment creation time. - testCases := createCases(paymentCreateSeconds) + testCases := createCases( + paymentCreateSecondsStart, paymentCreateSecondsEnd, + ) // We now check the timestamp filters in `ListPayments`. for _, tc := range testCases { @@ -578,7 +603,9 @@ func testListPayments(ht *lntest.HarnessTest) { } // Create test cases from the invoice creation time. - testCases = createCases(uint64(invoice.CreationDate)) + testCases = createCases( + uint64(invoice.CreationDate), uint64(invoice.CreationDate), + ) // We now do the same check for `ListInvoices`. for _, tc := range testCases { From ea4e183c39811283a94d7eded800bec2816192ab Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 16 Oct 2025 22:38:20 +0200 Subject: [PATCH 44/78] lnrpc: fix linter --- payments/db/payment_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index aa42b4ecaf2..5439a8b6bdb 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -426,7 +426,6 @@ func genAttemptWithHash(t *testing.T, attemptID uint64, // genInfo generates a payment creation info and the corresponding preimage. func genInfo(t *testing.T) (*PaymentCreationInfo, lntypes.Preimage, error) { - preimage, _, err := genPreimageAndHash(t) if err != nil { return nil, preimage, err From bfd59aac224e73a39fb5f957b9d869f980bf8549 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 17 Oct 2025 09:23:49 +0200 Subject: [PATCH 45/78] paymentsdb: add more comments --- lnrpc/routerrpc/router_backend.go | 4 ++++ payments/db/sql_converters.go | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index d8c8a17c410..3085f587dc1 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -1767,6 +1767,10 @@ func (r *RouterBackend) MarshallPayment(payment *paymentsdb.MPPayment) ( // If any of the htlcs have settled, extract a valid // preimage. if htlc.Settle != nil { + // For AMP payments all hashes will be different so we + // will only show the last htlc preimage, this is a + // current limitation for AMP payments because for + // MPP payments all hashes are the same. preimage = htlc.Settle.Preimage fee += htlc.Route.TotalFees() } diff --git a/payments/db/sql_converters.go b/payments/db/sql_converters.go index fd0cad2dcd1..66f3b1d3add 100644 --- a/payments/db/sql_converters.go +++ b/payments/db/sql_converters.go @@ -27,8 +27,10 @@ func dbPaymentToCreationInfo(paymentIdentifier []byte, amountMsat int64, copy(identifier[:], paymentIdentifier) return &PaymentCreationInfo{ - PaymentIdentifier: identifier, - Value: lnwire.MilliSatoshi(amountMsat), + PaymentIdentifier: identifier, + Value: lnwire.MilliSatoshi(amountMsat), + // The creation time is stored in the database as UTC but here + // we convert it to local time. CreationTime: createdAt.Local(), PaymentRequest: intentPayload, FirstHopCustomRecords: firstHopCustomRecords, @@ -205,7 +207,8 @@ func dbDataToRoute(hops []sqlc.FetchHopsForAttemptsRow, ) } - // Add blinding point if present (only for introduction node). + // Add blinding point if present (only for introduction node + // in blinded route). if len(hop.BlindingPoint) > 0 { pubKey, err := btcec.ParsePubKey(hop.BlindingPoint) if err != nil { From e40c8df16b6ff9cffb5691a4e4a5c15f1786a008 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 17 Oct 2025 09:23:27 +0200 Subject: [PATCH 46/78] paymentsdb: add firstcustom records to unit tests --- payments/db/payment_test.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 5439a8b6bdb..c788ca37d3b 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -383,11 +383,22 @@ func genPaymentCreationInfo(t *testing.T, t.Helper() + // Add constant first hop custom records for testing for testing + // purposes. + firstHopCustomRecords := lnwire.CustomRecords{ + lnwire.MinCustomRecordsTlvType + 1: []byte("test_record_1"), + lnwire.MinCustomRecordsTlvType + 2: []byte("test_record_2"), + lnwire.MinCustomRecordsTlvType + 3: []byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, + }, + } + return &PaymentCreationInfo{ - PaymentIdentifier: paymentHash, - Value: testRoute.ReceiverAmt(), - CreationTime: time.Unix(time.Now().Unix(), 0), - PaymentRequest: []byte("hola"), + PaymentIdentifier: paymentHash, + Value: testRoute.ReceiverAmt(), + CreationTime: time.Unix(time.Now().Unix(), 0), + PaymentRequest: []byte("hola"), + FirstHopCustomRecords: firstHopCustomRecords, } } From dfacb9ec00ac6d9680e84c1e95b401e8e94260cb Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 24 Nov 2025 18:59:31 +0100 Subject: [PATCH 47/78] paymentsdb: add unit test for FetchInflightPayments method --- payments/db/payment_test.go | 124 ++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index c788ca37d3b..ddba0e0285c 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -2710,3 +2710,127 @@ func TestQueryPayments(t *testing.T) { }) } } + +// TestFetchInFlightPayments tests that FetchInFlightPayments correctly returns +// only payments that are in-flight. +func TestFetchInFlightPayments(t *testing.T) { + t.Parallel() + + paymentDB, _ := NewTestDB(t) + + // Register payments with different statuses: + // 1. A payment with two failed attempts (StatusFailed). + // 2. A payment with one failed and one settled attempt + // (StatusSucceeded). + // 3. A payment with one failed and one in-flight attempt + // (StatusInFlight). + // 4. Another payment with one failed and one in-flight attempt + // (StatusInFlight). + payments := []*payment{ + {status: StatusFailed}, + {status: StatusSucceeded}, + {status: StatusInFlight}, + {status: StatusInFlight}, + } + + // Use helper function to register the test payments in the database and + // populate the data to the payments slice. + createTestPayments(t, paymentDB, payments) + + // Check that all payments are there as we added them. + assertDBPayments(t, paymentDB, payments) + + // Fetch in-flight payments. + inFlightPayments, err := paymentDB.FetchInFlightPayments() + require.NoError(t, err) + + // We should only get the two in-flight payments. + require.Len(t, inFlightPayments, 2) + + // Verify that the returned payments are the in-flight ones. + inFlightHashes := make(map[lntypes.Hash]struct{}) + for _, p := range inFlightPayments { + require.Equal(t, StatusInFlight, p.Status) + inFlightHashes[p.Info.PaymentIdentifier] = struct{}{} + } + + // Check that the in-flight payments match the expected ones. + require.Contains(t, inFlightHashes, payments[2].id) + require.Contains(t, inFlightHashes, payments[3].id) + + // Now settle one of the in-flight payments. + preimg, err := genPreimage(t) + require.NoError(t, err) + + _, err = paymentDB.SettleAttempt( + payments[2].id, 5, + &HTLCSettleInfo{ + Preimage: preimg, + }, + ) + require.NoError(t, err) + + // Fetch in-flight payments again. + inFlightPayments, err = paymentDB.FetchInFlightPayments() + require.NoError(t, err) + + // We should now only get one in-flight payment. + require.Len(t, inFlightPayments, 1) + require.Equal( + t, payments[3].id, + inFlightPayments[0].Info.PaymentIdentifier, + ) + require.Equal(t, StatusInFlight, inFlightPayments[0].Status) +} + +// TestFetchInFlightPaymentsMultipleAttempts tests that when fetching in-flight +// payments, a payment with multiple in-flight attempts is only returned once. +func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { + t.Parallel() + + paymentDB, _ := NewTestDB(t) + + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + // Init payment with double the amount to allow two attempts. + info.Value *= 2 + err = paymentDB.InitPayment(info.PaymentIdentifier, info) + require.NoError(t, err) + + // Register two attempts for the same payment. + attempt1, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + require.NoError(t, err) + + _, err = paymentDB.RegisterAttempt( + info.PaymentIdentifier, attempt1, + ) + require.NoError(t, err) + + attempt2, err := genAttemptWithHash(t, 1, genSessionKey(t), rhash) + require.NoError(t, err) + + _, err = paymentDB.RegisterAttempt( + info.PaymentIdentifier, attempt2, + ) + require.NoError(t, err) + + // Both attempts are in-flight. Fetch in-flight payments. + inFlightPayments, err := paymentDB.FetchInFlightPayments() + require.NoError(t, err) + + // We should only get one payment even though it has 2 in-flight + // attempts. + require.Len(t, inFlightPayments, 1) + require.Equal( + t, info.PaymentIdentifier, + inFlightPayments[0].Info.PaymentIdentifier, + ) + require.Equal(t, StatusInFlight, inFlightPayments[0].Status) + + // Verify the payment has both attempts. + require.Len(t, inFlightPayments[0].HTLCs, 2) +} From fcb8929ea5abecabbb9a356f797fd89fe6b95d58 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 16 Oct 2025 21:29:31 +0200 Subject: [PATCH 48/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index c3cea8e3aeb..fa74f5dfbce 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -209,7 +209,8 @@ SQL Backend](https://github.com/lightningnetwork/lnd/pull/10291) * Implement third(final) Part of SQL backend [payment functions](https://github.com/lightningnetwork/lnd/pull/10368) - + * Finalize SQL payments implementation [enabling unit and itests + for SQL backend](https://github.com/lightningnetwork/lnd/pull/10292) ## Code Health From bc15d4bb506cc72bcad0dc93d217e951f15a3ad8 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 20 Oct 2025 18:30:58 +0200 Subject: [PATCH 49/78] multi: thread context through DeletePayment --- payments/db/interface.go | 3 ++- payments/db/kv_store.go | 4 ++-- payments/db/payment_test.go | 36 ++++++++++++++++++++++++++---------- payments/db/sql_store.go | 4 +--- rpcserver.go | 2 +- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 7fefad08917..caf222b7ae0 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -31,7 +31,8 @@ type PaymentReader interface { // database. type PaymentWriter interface { // DeletePayment deletes a payment from the DB given its payment hash. - DeletePayment(paymentHash lntypes.Hash, failedAttemptsOnly bool) error + DeletePayment(ctx context.Context, paymentHash lntypes.Hash, + failedAttemptsOnly bool) error // DeletePayments deletes all payments from the DB given the specified // flags. diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 84946841b9b..138edb6fc49 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -295,7 +295,7 @@ func (p *KVStore) DeleteFailedAttempts(hash lntypes.Hash) error { // logic. This decision should be made in the application layer. if !p.keepFailedPaymentAttempts { const failedHtlcsOnly = true - err := p.DeletePayment(hash, failedHtlcsOnly) + err := p.DeletePayment(context.TODO(), hash, failedHtlcsOnly) if err != nil { return err } @@ -1275,7 +1275,7 @@ func fetchPaymentWithSequenceNumber(tx kvdb.RTx, paymentHash lntypes.Hash, // DeletePayment deletes a payment from the DB given its payment hash. If // failedHtlcsOnly is set, only failed HTLC attempts of the payment will be // deleted. -func (p *KVStore) DeletePayment(paymentHash lntypes.Hash, +func (p *KVStore) DeletePayment(_ context.Context, paymentHash lntypes.Hash, failedHtlcsOnly bool) error { return kvdb.Update(p.db, func(tx kvdb.RwTx) error { diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index ddba0e0285c..1180cf95374 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -511,8 +511,8 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { // operation are performed in general therefore we do NOT expect an // error in this case. if keepFailedPaymentAttempts { - require.NoError( - t, paymentDB.DeleteFailedAttempts(payments[1].id), + require.NoError(t, paymentDB.DeleteFailedAttempts( + payments[1].id), ) } else { require.Error(t, paymentDB.DeleteFailedAttempts(payments[1].id)) @@ -656,6 +656,8 @@ func TestMPPRecordValidation(t *testing.T) { func TestDeleteSinglePayment(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, _ := NewTestDB(t) // Register four payments: @@ -687,7 +689,9 @@ func TestDeleteSinglePayment(t *testing.T) { assertDBPayments(t, paymentDB, payments) // Delete HTLC attempts for first payment only. - require.NoError(t, paymentDB.DeletePayment(payments[0].id, true)) + require.NoError(t, paymentDB.DeletePayment( + ctx, payments[0].id, true, + )) // The first payment is the only altered one as its failed HTLC should // have been removed but is still present as payment. @@ -695,19 +699,25 @@ func TestDeleteSinglePayment(t *testing.T) { assertDBPayments(t, paymentDB, payments) // Delete the first payment completely. - require.NoError(t, paymentDB.DeletePayment(payments[0].id, false)) + require.NoError(t, paymentDB.DeletePayment( + ctx, payments[0].id, false, + )) // The first payment should have been deleted. assertDBPayments(t, paymentDB, payments[1:]) // Now delete the second payment completely. - require.NoError(t, paymentDB.DeletePayment(payments[1].id, false)) + require.NoError(t, paymentDB.DeletePayment( + ctx, payments[1].id, false, + )) // The Second payment should have been deleted. assertDBPayments(t, paymentDB, payments[2:]) // Delete failed HTLC attempts for the third payment. - require.NoError(t, paymentDB.DeletePayment(payments[2].id, true)) + require.NoError(t, paymentDB.DeletePayment( + ctx, payments[2].id, true, + )) // Only the successful HTLC attempt should be left for the third // payment. @@ -715,21 +725,27 @@ func TestDeleteSinglePayment(t *testing.T) { assertDBPayments(t, paymentDB, payments[2:]) // Now delete the third payment completely. - require.NoError(t, paymentDB.DeletePayment(payments[2].id, false)) + require.NoError(t, paymentDB.DeletePayment( + ctx, payments[2].id, false, + )) // Only the last payment should be left. assertDBPayments(t, paymentDB, payments[3:]) // Deleting HTLC attempts from InFlight payments should not work and an // error returned. - require.Error(t, paymentDB.DeletePayment(payments[3].id, true)) + require.Error(t, paymentDB.DeletePayment( + ctx, payments[3].id, true, + )) // The payment is InFlight and therefore should not have been altered. assertDBPayments(t, paymentDB, payments[3:]) // Finally deleting the InFlight payment should also not work and an // error returned. - require.Error(t, paymentDB.DeletePayment(payments[3].id, false)) + require.Error(t, paymentDB.DeletePayment( + ctx, payments[3].id, false, + )) // The payment is InFlight and therefore should not have been altered. assertDBPayments(t, paymentDB, payments[3:]) @@ -2597,7 +2613,7 @@ func TestQueryPayments(t *testing.T) { // We delete the whole payment. err = paymentDB.DeletePayment( - paymentInfos[1].PaymentIdentifier, false, + ctx, paymentInfos[1].PaymentIdentifier, false, ) require.NoError(t, err) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 0109ca1afa1..f6f5d0d37ad 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1206,11 +1206,9 @@ func computePaymentStatusFromDB(ctx context.Context, cfg *sqldb.QueryConfig, // // This method is part of the PaymentWriter interface, which is embedded in // the DB interface. -func (s *SQLStore) DeletePayment(paymentHash lntypes.Hash, +func (s *SQLStore) DeletePayment(ctx context.Context, paymentHash lntypes.Hash, failedHtlcsOnly bool) error { - ctx := context.TODO() - err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) if err != nil { diff --git a/rpcserver.go b/rpcserver.go index 8cbeb743b92..df27c41ce0a 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -7741,7 +7741,7 @@ func (r *rpcServer) DeletePayment(ctx context.Context, rpcsLog.Infof("[DeletePayment] payment_identifier=%v, "+ "failed_htlcs_only=%v", hash, req.FailedHtlcsOnly) - err = r.server.paymentsDB.DeletePayment(hash, req.FailedHtlcsOnly) + err = r.server.paymentsDB.DeletePayment(ctx, hash, req.FailedHtlcsOnly) if err != nil { return nil, err } From 563a2ac9d30145e58130f62882f58fdf9aa7da47 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 20 Oct 2025 19:04:14 +0200 Subject: [PATCH 50/78] multi: thread context through DeletePayments --- payments/db/interface.go | 3 ++- payments/db/kv_store.go | 2 +- payments/db/kv_store_test.go | 6 ++++-- payments/db/payment_test.go | 10 ++++++---- payments/db/sql_store.go | 10 +++++----- rpcserver.go | 2 +- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index caf222b7ae0..c6f1bf35312 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -36,7 +36,8 @@ type PaymentWriter interface { // DeletePayments deletes all payments from the DB given the specified // flags. - DeletePayments(failedOnly, failedAttemptsOnly bool) (int, error) + DeletePayments(ctx context.Context, failedOnly, + failedAttemptsOnly bool) (int, error) PaymentControl } diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 138edb6fc49..d3d347afe97 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -1372,7 +1372,7 @@ func (p *KVStore) DeletePayment(_ context.Context, paymentHash lntypes.Hash, // failedHtlcsOnly is set, the payment itself won't be deleted, only failed HTLC // attempts. The method returns the number of deleted payments, which is always // 0 if failedHtlcsOnly is set. -func (p *KVStore) DeletePayments(failedOnly, +func (p *KVStore) DeletePayments(_ context.Context, failedOnly, failedHtlcsOnly bool) (int, error) { var numPayments int diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index f0c2b148fd0..cf5a9a9a001 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -30,6 +30,8 @@ import ( func TestKVStoreDeleteNonInFlight(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB := NewKVTestDB(t) // Create a sequence number for duplicate payments that will not collide @@ -180,7 +182,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { } // Delete all failed payments. - numPayments, err := paymentDB.DeletePayments(true, false) + numPayments, err := paymentDB.DeletePayments(ctx, true, false) require.NoError(t, err) require.EqualValues(t, 1, numPayments) @@ -216,7 +218,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { } // Now delete all payments except in-flight. - numPayments, err = paymentDB.DeletePayments(false, false) + numPayments, err = paymentDB.DeletePayments(ctx, false, false) require.NoError(t, err) require.EqualValues(t, 2, numPayments) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 1180cf95374..4a9a69b0ce6 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -1658,6 +1658,8 @@ func TestFailsWithoutInFlight(t *testing.T) { func TestDeletePayments(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, _ := NewTestDB(t) // Register three payments: @@ -1678,7 +1680,7 @@ func TestDeletePayments(t *testing.T) { assertDBPayments(t, paymentDB, payments) // Delete HTLC attempts for failed payments only. - numPayments, err := paymentDB.DeletePayments(true, true) + numPayments, err := paymentDB.DeletePayments(ctx, true, true) require.NoError(t, err) require.EqualValues(t, 0, numPayments) @@ -1687,7 +1689,7 @@ func TestDeletePayments(t *testing.T) { assertDBPayments(t, paymentDB, payments) // Delete failed attempts for all payments. - numPayments, err = paymentDB.DeletePayments(false, true) + numPayments, err = paymentDB.DeletePayments(ctx, false, true) require.NoError(t, err) require.EqualValues(t, 0, numPayments) @@ -1697,14 +1699,14 @@ func TestDeletePayments(t *testing.T) { assertDBPayments(t, paymentDB, payments) // Now delete all failed payments. - numPayments, err = paymentDB.DeletePayments(true, false) + numPayments, err = paymentDB.DeletePayments(ctx, true, false) require.NoError(t, err) require.EqualValues(t, 1, numPayments) assertDBPayments(t, paymentDB, payments[1:]) // Finally delete all completed payments. - numPayments, err = paymentDB.DeletePayments(false, false) + numPayments, err = paymentDB.DeletePayments(ctx, false, false) require.NoError(t, err) require.EqualValues(t, 1, numPayments) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index f6f5d0d37ad..ef0d96c93cd 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1874,13 +1874,13 @@ func (s *SQLStore) Fail(paymentHash lntypes.Hash, // This method is part of the PaymentWriter interface, which is embedded in // the DB interface. // -// TODO(ziggie): batch this call instead in the background so for dbs with -// many payments it doesn't block the main thread. -func (s *SQLStore) DeletePayments(failedOnly, failedHtlcsOnly bool) (int, - error) { +// TODO(ziggie): batch and use iterator instead, moreover we dont need to fetch +// the complete payment data for each payment, we can just fetch the payment ID +// and the resolution types to decide if the payment is removable. +func (s *SQLStore) DeletePayments(ctx context.Context, failedOnly, + failedHtlcsOnly bool) (int, error) { var numPayments int - ctx := context.TODO() extractCursor := func(row sqlc.FilterPaymentsRow) int64 { return row.Payment.ID diff --git a/rpcserver.go b/rpcserver.go index df27c41ce0a..2f88b2db83a 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -7782,7 +7782,7 @@ func (r *rpcServer) DeleteAllPayments(ctx context.Context, req.FailedHtlcsOnly) numDeletedPayments, err := r.server.paymentsDB.DeletePayments( - req.FailedPaymentsOnly, req.FailedHtlcsOnly, + ctx, req.FailedPaymentsOnly, req.FailedHtlcsOnly, ) if err != nil { return nil, err From 25d05be0384373da2c0053555814e90835ba62ed Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 20 Oct 2025 20:16:47 +0200 Subject: [PATCH 51/78] multi: thread context through FetchPayment --- payments/db/interface.go | 3 ++- payments/db/kv_store.go | 4 ++-- payments/db/kv_store_test.go | 10 ++++++---- payments/db/payment_test.go | 16 +++++++++++----- payments/db/sql_store.go | 4 ++-- payments/db/test_kvdb.go | 4 +++- routing/control_tower.go | 17 ++++++++++++----- routing/mock_test.go | 9 +++++---- routing/payment_lifecycle.go | 8 ++++++-- routing/router.go | 4 +++- routing/router_test.go | 4 +++- 11 files changed, 55 insertions(+), 28 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index c6f1bf35312..5368d53d328 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -21,7 +21,8 @@ type PaymentReader interface { // FetchPayment fetches the payment corresponding to the given payment // hash. - FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) + FetchPayment(ctx context.Context, + paymentHash lntypes.Hash) (*MPPayment, error) // FetchInFlightPayments returns all payments with status InFlight. FetchInFlightPayments() ([]*MPPayment, error) diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index d3d347afe97..86b37edcf72 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -585,8 +585,8 @@ func (p *KVStore) Fail(paymentHash lntypes.Hash, } // FetchPayment returns information about a payment from the database. -func (p *KVStore) FetchPayment(paymentHash lntypes.Hash) ( - *MPPayment, error) { +func (p *KVStore) FetchPayment(_ context.Context, + paymentHash lntypes.Hash) (*MPPayment, error) { var payment *MPPayment err := kvdb.View(p.db, func(tx kvdb.RTx) error { diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index cf5a9a9a001..910c1812dca 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -409,6 +409,8 @@ func deletePayment(t *testing.T, db kvdb.Backend, paymentHash lntypes.Hash, func TestFetchPaymentWithSequenceNumber(t *testing.T) { paymentDB := NewKVTestDB(t) + ctx := t.Context() + // Generate a test payment which does not have duplicates. noDuplicates, _, err := genInfo(t) require.NoError(t, err) @@ -421,7 +423,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { // Fetch the payment so we can get its sequence nr. noDuplicatesPayment, err := paymentDB.FetchPayment( - noDuplicates.PaymentIdentifier, + ctx, noDuplicates.PaymentIdentifier, ) require.NoError(t, err) @@ -437,7 +439,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { // Fetch the payment so we can get its sequence nr. hasDuplicatesPayment, err := paymentDB.FetchPayment( - hasDuplicates.PaymentIdentifier, + ctx, hasDuplicates.PaymentIdentifier, ) require.NoError(t, err) @@ -749,7 +751,7 @@ func TestKVStoreQueryPaymentsDuplicates(t *testing.T) { // Immediately delete the payment with index 2. if i == 1 { pmt, err := paymentDB.FetchPayment( - info.PaymentIdentifier, + ctx, info.PaymentIdentifier, ) require.NoError(t, err) @@ -766,7 +768,7 @@ func TestKVStoreQueryPaymentsDuplicates(t *testing.T) { // duplicate payments will always be succeeded. if i == (nonDuplicatePayments - 1) { pmt, err := paymentDB.FetchPayment( - info.PaymentIdentifier, + ctx, info.PaymentIdentifier, ) require.NoError(t, err) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 4a9a69b0ce6..8954f8a5a75 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -235,7 +235,9 @@ func assertPaymentInfo(t *testing.T, p DB, hash lntypes.Hash, t.Helper() - payment, err := p.FetchPayment(hash) + ctx := t.Context() + + payment, err := p.FetchPayment(ctx, hash) if err != nil { t.Fatal(err) } @@ -303,7 +305,9 @@ func assertDBPaymentstatus(t *testing.T, p DB, hash lntypes.Hash, t.Helper() - payment, err := p.FetchPayment(hash) + ctx := t.Context() + + payment, err := p.FetchPayment(ctx, hash) if errors.Is(err, ErrPaymentNotInitiated) { return } @@ -1796,6 +1800,8 @@ func TestSwitchDoubleSend(t *testing.T) { func TestSwitchFail(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, harness := NewTestDB(t) preimg, err := genPreimage(t) @@ -1834,7 +1840,7 @@ func TestSwitchFail(t *testing.T) { // Lookup the payment so we can get its old sequence number before it is // overwritten. - payment, err := paymentDB.FetchPayment(info.PaymentIdentifier) + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) require.NoError(t, err) // Sends the htlc again, which should succeed since the prior payment @@ -2609,7 +2615,7 @@ func TestQueryPayments(t *testing.T) { // Now delete the payment at index 1 (the second // payment). pmt, err := paymentDB.FetchPayment( - paymentInfos[1].PaymentIdentifier, + ctx, paymentInfos[1].PaymentIdentifier, ) require.NoError(t, err) @@ -2621,7 +2627,7 @@ func TestQueryPayments(t *testing.T) { // Verify the payment is deleted. _, err = paymentDB.FetchPayment( - paymentInfos[1].PaymentIdentifier, + ctx, paymentInfos[1].PaymentIdentifier, ) require.ErrorIs( t, err, ErrPaymentNotInitiated, diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index ef0d96c93cd..94233649e8d 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -922,8 +922,8 @@ func fetchPaymentByHash(ctx context.Context, db SQLQueries, // Returns ErrPaymentNotInitiated if no payment with the given hash exists. // // This is part of the DB interface. -func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { - ctx := context.TODO() +func (s *SQLStore) FetchPayment(ctx context.Context, + paymentHash lntypes.Hash) (*MPPayment, error) { var mpPayment *MPPayment diff --git a/payments/db/test_kvdb.go b/payments/db/test_kvdb.go index ed1710b14fa..c2de0b43f04 100644 --- a/payments/db/test_kvdb.go +++ b/payments/db/test_kvdb.go @@ -57,9 +57,11 @@ func (h *kvTestHarness) AssertPaymentIndex(t *testing.T, t.Helper() + ctx := t.Context() + // Lookup the payment so that we have its sequence number and check // that it has correctly been indexed in the payment indexes bucket. - pmt, err := h.db.FetchPayment(expectedHash) + pmt, err := h.db.FetchPayment(ctx, expectedHash) require.NoError(t, err) hash, err := h.fetchPaymentIndexEntry(t, pmt.SequenceNum) diff --git a/routing/control_tower.go b/routing/control_tower.go index 2b9e7dd9d28..31028948775 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -1,6 +1,7 @@ package routing import ( + "context" "sync" "github.com/lightningnetwork/lnd/lntypes" @@ -52,7 +53,8 @@ type ControlTower interface { // FetchPayment fetches the payment corresponding to the given payment // hash. - FetchPayment(paymentHash lntypes.Hash) (paymentsdb.DBMPPayment, error) + FetchPayment(ctx context.Context, + paymentHash lntypes.Hash) (paymentsdb.DBMPPayment, error) // FailPayment transitions a payment into the Failed state, and records // the ultimate reason the payment failed. Note that this should only @@ -164,6 +166,8 @@ func NewControlTower(db paymentsdb.DB) ControlTower { func (p *controlTower) InitPayment(paymentHash lntypes.Hash, info *paymentsdb.PaymentCreationInfo) error { + ctx := context.TODO() + err := p.db.InitPayment(paymentHash, info) if err != nil { return err @@ -174,7 +178,7 @@ func (p *controlTower) InitPayment(paymentHash lntypes.Hash, p.paymentsMtx.Lock(paymentHash) defer p.paymentsMtx.Unlock(paymentHash) - payment, err := p.db.FetchPayment(paymentHash) + payment, err := p.db.FetchPayment(ctx, paymentHash) if err != nil { return err } @@ -250,10 +254,11 @@ func (p *controlTower) FailAttempt(paymentHash lntypes.Hash, } // FetchPayment fetches the payment corresponding to the given payment hash. -func (p *controlTower) FetchPayment(paymentHash lntypes.Hash) ( +func (p *controlTower) FetchPayment(ctx context.Context, + paymentHash lntypes.Hash) ( paymentsdb.DBMPPayment, error) { - return p.db.FetchPayment(paymentHash) + return p.db.FetchPayment(ctx, paymentHash) } // FailPayment transitions a payment into the Failed state, and records the @@ -293,12 +298,14 @@ func (p *controlTower) FetchInFlightPayments() ([]*paymentsdb.MPPayment, func (p *controlTower) SubscribePayment(paymentHash lntypes.Hash) ( ControlTowerSubscriber, error) { + ctx := context.TODO() + // Take lock before querying the db to prevent missing or duplicating an // update. p.paymentsMtx.Lock(paymentHash) defer p.paymentsMtx.Unlock(paymentHash) - payment, err := p.db.FetchPayment(paymentHash) + payment, err := p.db.FetchPayment(ctx, paymentHash) if err != nil { return nil, err } diff --git a/routing/mock_test.go b/routing/mock_test.go index 19a76ee9010..556601ecd0e 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -1,6 +1,7 @@ package routing import ( + "context" "errors" "fmt" "sync" @@ -509,8 +510,8 @@ func (m *mockControlTowerOld) FailPayment(phash lntypes.Hash, return nil } -func (m *mockControlTowerOld) FetchPayment(phash lntypes.Hash) ( - paymentsdb.DBMPPayment, error) { +func (m *mockControlTowerOld) FetchPayment(_ context.Context, + phash lntypes.Hash) (paymentsdb.DBMPPayment, error) { m.Lock() defer m.Unlock() @@ -786,8 +787,8 @@ func (m *mockControlTower) FailPayment(phash lntypes.Hash, return args.Error(0) } -func (m *mockControlTower) FetchPayment(phash lntypes.Hash) ( - paymentsdb.DBMPPayment, error) { +func (m *mockControlTower) FetchPayment(_ context.Context, + phash lntypes.Hash) (paymentsdb.DBMPPayment, error) { args := m.Called(phash) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 8353cba157f..4eb78c8e22e 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -1114,7 +1114,9 @@ func (p *paymentLifecycle) patchLegacyPaymentHash( func (p *paymentLifecycle) reloadInflightAttempts() (paymentsdb.DBMPPayment, error) { - payment, err := p.router.cfg.Control.FetchPayment(p.identifier) + ctx := context.TODO() + + payment, err := p.router.cfg.Control.FetchPayment(ctx, p.identifier) if err != nil { return nil, err } @@ -1139,8 +1141,10 @@ func (p *paymentLifecycle) reloadInflightAttempts() (paymentsdb.DBMPPayment, func (p *paymentLifecycle) reloadPayment() (paymentsdb.DBMPPayment, *paymentsdb.MPPaymentState, error) { + ctx := context.TODO() + // Read the db to get the latest state of the payment. - payment, err := p.router.cfg.Control.FetchPayment(p.identifier) + payment, err := p.router.cfg.Control.FetchPayment(ctx, p.identifier) if err != nil { return nil, nil, err } diff --git a/routing/router.go b/routing/router.go index 19df5b921f8..8aa5acf5acd 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1064,13 +1064,15 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, firstHopCustomRecords lnwire.CustomRecords) (*paymentsdb.HTLCAttempt, error) { + ctx := context.TODO() + // Helper function to fail a payment. It makes sure the payment is only // failed once so that the failure reason is not overwritten. failPayment := func(paymentIdentifier lntypes.Hash, reason paymentsdb.FailureReason) error { payment, fetchErr := r.cfg.Control.FetchPayment( - paymentIdentifier, + ctx, paymentIdentifier, ) if fetchErr != nil { return fetchErr diff --git a/routing/router_test.go b/routing/router_test.go index 115c02c2c97..733943272d9 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -1097,7 +1097,9 @@ func TestSendPaymentErrorPathPruning(t *testing.T) { require.Equal(t, paymentsdb.FailureReasonNoRoute, err) // Inspect the two attempts that were made before the payment failed. - p, err := ctx.router.cfg.Control.FetchPayment(*payment.paymentHash) + p, err := ctx.router.cfg.Control.FetchPayment( + t.Context(), *payment.paymentHash, + ) require.NoError(t, err) htlcs := p.GetHTLCs() From 10062ca9ac048d82b8426fe1393b8c56afb39ace Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 20 Oct 2025 20:24:32 +0200 Subject: [PATCH 52/78] multi: thread context through FetchInflightPayments --- payments/db/interface.go | 2 +- payments/db/kv_store.go | 4 +++- payments/db/payment_test.go | 10 +++++++--- payments/db/sql_store.go | 4 +--- routing/control_tower.go | 13 ++++++++----- routing/mock_test.go | 4 ++-- routing/router.go | 4 +++- 7 files changed, 25 insertions(+), 16 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 5368d53d328..616906c738e 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -25,7 +25,7 @@ type PaymentReader interface { paymentHash lntypes.Hash) (*MPPayment, error) // FetchInFlightPayments returns all payments with status InFlight. - FetchInFlightPayments() ([]*MPPayment, error) + FetchInFlightPayments(ctx context.Context) ([]*MPPayment, error) } // PaymentWriter represents the interface to write operations to the payments diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 86b37edcf72..1b48cac67ec 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -741,7 +741,9 @@ func fetchPaymentStatus(bucket kvdb.RBucket) (PaymentStatus, error) { } // FetchInFlightPayments returns all payments with status InFlight. -func (p *KVStore) FetchInFlightPayments() ([]*MPPayment, error) { +func (p *KVStore) FetchInFlightPayments(_ context.Context) ([]*MPPayment, + error) { + var ( inFlights []*MPPayment start = time.Now() diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 8954f8a5a75..cc1c6e9199d 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -2740,6 +2740,8 @@ func TestQueryPayments(t *testing.T) { func TestFetchInFlightPayments(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, _ := NewTestDB(t) // Register payments with different statuses: @@ -2765,7 +2767,7 @@ func TestFetchInFlightPayments(t *testing.T) { assertDBPayments(t, paymentDB, payments) // Fetch in-flight payments. - inFlightPayments, err := paymentDB.FetchInFlightPayments() + inFlightPayments, err := paymentDB.FetchInFlightPayments(ctx) require.NoError(t, err) // We should only get the two in-flight payments. @@ -2795,7 +2797,7 @@ func TestFetchInFlightPayments(t *testing.T) { require.NoError(t, err) // Fetch in-flight payments again. - inFlightPayments, err = paymentDB.FetchInFlightPayments() + inFlightPayments, err = paymentDB.FetchInFlightPayments(ctx) require.NoError(t, err) // We should now only get one in-flight payment. @@ -2812,6 +2814,8 @@ func TestFetchInFlightPayments(t *testing.T) { func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, _ := NewTestDB(t) preimg, err := genPreimage(t) @@ -2843,7 +2847,7 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { require.NoError(t, err) // Both attempts are in-flight. Fetch in-flight payments. - inFlightPayments, err := paymentDB.FetchInFlightPayments() + inFlightPayments, err := paymentDB.FetchInFlightPayments(ctx) require.NoError(t, err) // We should only get one payment even though it has 2 in-flight diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 94233649e8d..7fad7cc7e43 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -972,11 +972,9 @@ func (s *SQLStore) FetchPayment(ctx context.Context, // While inflight payments are typically a small subset, this would improve // memory efficiency for nodes with unusually high numbers of concurrent // payments and would better leverage the existing pagination infrastructure. -func (s *SQLStore) FetchInFlightPayments() ([]*MPPayment, +func (s *SQLStore) FetchInFlightPayments(ctx context.Context) ([]*MPPayment, error) { - ctx := context.TODO() - var mpPayments []*MPPayment err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { diff --git a/routing/control_tower.go b/routing/control_tower.go index 31028948775..718dca3ff54 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -67,7 +67,8 @@ type ControlTower interface { FailPayment(lntypes.Hash, paymentsdb.FailureReason) error // FetchInFlightPayments returns all payments with status InFlight. - FetchInFlightPayments() ([]*paymentsdb.MPPayment, error) + FetchInFlightPayments(ctx context.Context) ([]*paymentsdb.MPPayment, + error) // SubscribePayment subscribes to updates for the payment with the given // hash. A first update with the current state of the payment is always @@ -286,10 +287,10 @@ func (p *controlTower) FailPayment(paymentHash lntypes.Hash, } // FetchInFlightPayments returns all payments with status InFlight. -func (p *controlTower) FetchInFlightPayments() ([]*paymentsdb.MPPayment, - error) { +func (p *controlTower) FetchInFlightPayments( + ctx context.Context) ([]*paymentsdb.MPPayment, error) { - return p.db.FetchInFlightPayments() + return p.db.FetchInFlightPayments(ctx) } // SubscribePayment subscribes to updates for the payment with the given hash. A @@ -342,6 +343,8 @@ func (p *controlTower) SubscribePayment(paymentHash lntypes.Hash) ( func (p *controlTower) SubscribeAllPayments() (ControlTowerSubscriber, error) { subscriber := newControlTowerSubscriber() + ctx := context.TODO() + // Add the subscriber to the list before fetching in-flight payments, so // no events are missed. If a payment attempt update occurs after // appending and before fetching in-flight payments, an out-of-order @@ -353,7 +356,7 @@ func (p *controlTower) SubscribeAllPayments() (ControlTowerSubscriber, error) { p.subscribersMtx.Unlock() log.Debugf("Scanning for inflight payments") - inflightPayments, err := p.db.FetchInFlightPayments() + inflightPayments, err := p.db.FetchInFlightPayments(ctx) if err != nil { return nil, err } diff --git a/routing/mock_test.go b/routing/mock_test.go index 556601ecd0e..b30627165da 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -546,7 +546,7 @@ func (m *mockControlTowerOld) fetchPayment(phash lntypes.Hash) ( return mp, nil } -func (m *mockControlTowerOld) FetchInFlightPayments() ( +func (m *mockControlTowerOld) FetchInFlightPayments(_ context.Context) ( []*paymentsdb.MPPayment, error) { if m.fetchInFlight != nil { @@ -801,7 +801,7 @@ func (m *mockControlTower) FetchPayment(_ context.Context, return payment, args.Error(1) } -func (m *mockControlTower) FetchInFlightPayments() ( +func (m *mockControlTower) FetchInFlightPayments(_ context.Context) ( []*paymentsdb.MPPayment, error) { args := m.Called() diff --git a/routing/router.go b/routing/router.go index 8aa5acf5acd..2aa5745e916 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1417,9 +1417,11 @@ func (r *ChannelRouter) BuildRoute(amt fn.Option[lnwire.MilliSatoshi], // resumePayments fetches inflight payments and resumes their payment // lifecycles. func (r *ChannelRouter) resumePayments() error { + ctx := context.TODO() + // Get all payments that are inflight. log.Debugf("Scanning for inflight payments") - payments, err := r.cfg.Control.FetchInFlightPayments() + payments, err := r.cfg.Control.FetchInFlightPayments(ctx) if err != nil { return err } From 60026494b1e06be92b6f634bb234d20f14547631 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 20 Oct 2025 22:00:16 +0200 Subject: [PATCH 53/78] multi: thread context through InitPayment --- payments/db/interface.go | 2 +- payments/db/kv_store.go | 2 +- payments/db/kv_store_test.go | 8 ++++---- payments/db/payment_test.go | 34 +++++++++++++++++++++------------- payments/db/sql_store.go | 4 +--- routing/control_tower.go | 11 +++++------ routing/control_tower_test.go | 12 ++++++------ routing/mock_test.go | 6 +++--- routing/router.go | 6 ++++-- 9 files changed, 46 insertions(+), 39 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 616906c738e..2d0335609d2 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -61,7 +61,7 @@ type PaymentControl interface { // exists in the database before creating a new payment. However, it // should allow the user making a subsequent payment if the payment is // in a Failed state. - InitPayment(lntypes.Hash, *PaymentCreationInfo) error + InitPayment(context.Context, lntypes.Hash, *PaymentCreationInfo) error // RegisterAttempt atomically records the provided HTLCAttemptInfo. // diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 1b48cac67ec..81b257ce86f 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -186,7 +186,7 @@ func initKVStore(db kvdb.Backend) error { // making sure it does not already exist as an in-flight payment. When this // method returns successfully, the payment is guaranteed to be in the InFlight // state. -func (p *KVStore) InitPayment(paymentHash lntypes.Hash, +func (p *KVStore) InitPayment(_ context.Context, paymentHash lntypes.Hash, info *PaymentCreationInfo) error { // Obtain a new sequence number for this payment. This is used diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index 910c1812dca..6837134e7d4 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -80,7 +80,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { require.NoError(t, err) // Sends base htlc message which initiate StatusInFlight. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) if err != nil { t.Fatalf("unable to send htlc message: %v", err) } @@ -417,7 +417,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { // Create a new payment entry in the database. err = paymentDB.InitPayment( - noDuplicates.PaymentIdentifier, noDuplicates, + ctx, noDuplicates.PaymentIdentifier, noDuplicates, ) require.NoError(t, err) @@ -433,7 +433,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { // Create a new payment entry in the database. err = paymentDB.InitPayment( - hasDuplicates.PaymentIdentifier, hasDuplicates, + ctx, hasDuplicates.PaymentIdentifier, hasDuplicates, ) require.NoError(t, err) @@ -744,7 +744,7 @@ func TestKVStoreQueryPaymentsDuplicates(t *testing.T) { // Create a new payment entry in the database. err = paymentDB.InitPayment( - info.PaymentIdentifier, info, + ctx, info.PaymentIdentifier, info, ) require.NoError(t, err) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index cc1c6e9199d..4b2cbcbd82d 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -125,6 +125,8 @@ type payment struct { func createTestPayments(t *testing.T, p DB, payments []*payment) { t.Helper() + ctx := t.Context() + attemptID := uint64(0) for i := 0; i < len(payments); i++ { @@ -145,7 +147,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { attemptID++ // Init the payment. - err = p.InitPayment(info.PaymentIdentifier, info) + err = p.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") // Register and fail the first attempt for all payments. @@ -559,6 +561,8 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { func TestMPPRecordValidation(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, _ := NewTestDB(t) preimg, err := genPreimage(t) @@ -575,7 +579,7 @@ func TestMPPRecordValidation(t *testing.T) { require.NoError(t, err, "unable to generate htlc message") // Init the payment. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") // Create three unique attempts we'll use for the test, and @@ -633,7 +637,7 @@ func TestMPPRecordValidation(t *testing.T) { require.NoError(t, err, "unable to generate htlc message") - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") attempt.Route.FinalHop().MPP = nil @@ -1722,6 +1726,8 @@ func TestDeletePayments(t *testing.T) { func TestSwitchDoubleSend(t *testing.T) { t.Parallel() + ctx := t.Context() + paymentDB, harness := NewTestDB(t) preimg, err := genPreimage(t) @@ -1734,7 +1740,7 @@ func TestSwitchDoubleSend(t *testing.T) { // Sends base htlc message which initiate base status and move it to // StatusInFlight and verifies that it was changed. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") harness.AssertPaymentIndex(t, info.PaymentIdentifier) @@ -1748,7 +1754,7 @@ func TestSwitchDoubleSend(t *testing.T) { // Try to initiate double sending of htlc message with the same // payment hash, should result in error indicating that payment has // already been sent. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.ErrorIs(t, err, ErrPaymentExists) // Record an attempt. @@ -1766,7 +1772,7 @@ func TestSwitchDoubleSend(t *testing.T) { ) // Sends base htlc message which initiate StatusInFlight. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) if !errors.Is(err, ErrPaymentInFlight) { t.Fatalf("payment control wrong behaviour: " + "double sending must trigger ErrPaymentInFlight error") @@ -1789,7 +1795,7 @@ func TestSwitchDoubleSend(t *testing.T) { t, paymentDB, info.PaymentIdentifier, info, nil, htlc, ) - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) if !errors.Is(err, ErrAlreadyPaid) { t.Fatalf("unable to send htlc message: %v", err) } @@ -1813,7 +1819,7 @@ func TestSwitchFail(t *testing.T) { require.NoError(t, err) // Sends base htlc message which initiate StatusInFlight. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") harness.AssertPaymentIndex(t, info.PaymentIdentifier) @@ -1845,7 +1851,7 @@ func TestSwitchFail(t *testing.T) { // Sends the htlc again, which should succeed since the prior payment // failed. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") // Check that our index has been updated, and the old index has been @@ -1940,7 +1946,7 @@ func TestSwitchFail(t *testing.T) { // Attempt a final payment, which should now fail since the prior // payment succeed. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) if !errors.Is(err, ErrAlreadyPaid) { t.Fatalf("unable to send htlc message: %v", err) } @@ -1951,6 +1957,8 @@ func TestSwitchFail(t *testing.T) { func TestMultiShard(t *testing.T) { t.Parallel() + ctx := t.Context() + // We will register three HTLC attempts, and always fail the second // one. We'll generate all combinations of settling/failing the first // and third HTLC, and assert that the payment status end up as we @@ -1977,7 +1985,7 @@ func TestMultiShard(t *testing.T) { info := genPaymentCreationInfo(t, rhash) // Init the payment, moving it to the StatusInFlight state. - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err) harness.AssertPaymentIndex(t, info.PaymentIdentifier) @@ -2607,7 +2615,7 @@ func TestQueryPayments(t *testing.T) { // Create a new payment entry in the database. err = paymentDB.InitPayment( - info.PaymentIdentifier, info, + ctx, info.PaymentIdentifier, info, ) require.NoError(t, err) } @@ -2826,7 +2834,7 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { // Init payment with double the amount to allow two attempts. info.Value *= 2 - err = paymentDB.InitPayment(info.PaymentIdentifier, info) + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err) // Register two attempts for the same payment. diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 7fad7cc7e43..cacd5cdd6b7 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1265,11 +1265,9 @@ func (s *SQLStore) DeletePayment(ctx context.Context, paymentHash lntypes.Hash, // This method is part of the PaymentControl interface, which is embedded in // the PaymentWriter interface and ultimately the DB interface, representing // the first step in the payment lifecycle control flow. -func (s *SQLStore) InitPayment(paymentHash lntypes.Hash, +func (s *SQLStore) InitPayment(ctx context.Context, paymentHash lntypes.Hash, paymentCreationInfo *PaymentCreationInfo) error { - ctx := context.TODO() - // Create the payment in the database. err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { existingPayment, err := db.FetchPayment(ctx, paymentHash[:]) diff --git a/routing/control_tower.go b/routing/control_tower.go index 718dca3ff54..8df87b473bf 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -20,7 +20,8 @@ type ControlTower interface { // also notifies subscribers of the payment creation. // // NOTE: Subscribers should be notified by the new state of the payment. - InitPayment(lntypes.Hash, *paymentsdb.PaymentCreationInfo) error + InitPayment(context.Context, lntypes.Hash, + *paymentsdb.PaymentCreationInfo) error // DeleteFailedAttempts removes all failed HTLCs from the db. It should // be called for a given payment whenever all inflight htlcs are @@ -164,12 +165,10 @@ func NewControlTower(db paymentsdb.DB) ControlTower { // making sure it does not already exist as an in-flight payment. Then this // method returns successfully, the payment is guaranteed to be in the // Initiated state. -func (p *controlTower) InitPayment(paymentHash lntypes.Hash, - info *paymentsdb.PaymentCreationInfo) error { +func (p *controlTower) InitPayment(ctx context.Context, + paymentHash lntypes.Hash, info *paymentsdb.PaymentCreationInfo) error { - ctx := context.TODO() - - err := p.db.InitPayment(paymentHash, info) + err := p.db.InitPayment(ctx, paymentHash, info) if err != nil { return err } diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go index de0aacf880b..0993fb2a688 100644 --- a/routing/control_tower_test.go +++ b/routing/control_tower_test.go @@ -81,7 +81,7 @@ func TestControlTowerSubscribeSuccess(t *testing.T) { t.Fatal(err) } - err = pControl.InitPayment(info.PaymentIdentifier, info) + err = pControl.InitPayment(t.Context(), info.PaymentIdentifier, info) if err != nil { t.Fatal(err) } @@ -212,7 +212,7 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { info1, attempt1, preimg1, err := genInfo() require.NoError(t, err) - err = pControl.InitPayment(info1.PaymentIdentifier, info1) + err = pControl.InitPayment(t.Context(), info1.PaymentIdentifier, info1) require.NoError(t, err) // Subscription should succeed and immediately report the Initiated @@ -228,7 +228,7 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { info2, attempt2, preimg2, err := genInfo() require.NoError(t, err) - err = pControl.InitPayment(info2.PaymentIdentifier, info2) + err = pControl.InitPayment(t.Context(), info2.PaymentIdentifier, info2) require.NoError(t, err) // Register an attempt on the second payment. @@ -337,7 +337,7 @@ func TestKVStoreSubscribeAllImmediate(t *testing.T) { info, attempt, _, err := genInfo() require.NoError(t, err) - err = pControl.InitPayment(info.PaymentIdentifier, info) + err = pControl.InitPayment(t.Context(), info.PaymentIdentifier, info) require.NoError(t, err) // Register a payment update. @@ -392,7 +392,7 @@ func TestKVStoreUnsubscribeSuccess(t *testing.T) { info, attempt, _, err := genInfo() require.NoError(t, err) - err = pControl.InitPayment(info.PaymentIdentifier, info) + err = pControl.InitPayment(t.Context(), info.PaymentIdentifier, info) require.NoError(t, err) // Assert all subscriptions receive the update. @@ -465,7 +465,7 @@ func testKVStoreSubscribeFail(t *testing.T, registerAttempt, t.Fatal(err) } - err = pControl.InitPayment(info.PaymentIdentifier, info) + err = pControl.InitPayment(t.Context(), info.PaymentIdentifier, info) if err != nil { t.Fatal(err) } diff --git a/routing/mock_test.go b/routing/mock_test.go index b30627165da..5b9d4854b13 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -297,8 +297,8 @@ func makeMockControlTower() *mockControlTowerOld { } } -func (m *mockControlTowerOld) InitPayment(phash lntypes.Hash, - c *paymentsdb.PaymentCreationInfo) error { +func (m *mockControlTowerOld) InitPayment(_ context.Context, + phash lntypes.Hash, c *paymentsdb.PaymentCreationInfo) error { if m.init != nil { m.init <- initArgs{c} @@ -734,7 +734,7 @@ type mockControlTower struct { var _ ControlTower = (*mockControlTower)(nil) -func (m *mockControlTower) InitPayment(phash lntypes.Hash, +func (m *mockControlTower) InitPayment(_ context.Context, phash lntypes.Hash, c *paymentsdb.PaymentCreationInfo) error { args := m.Called(phash, c) diff --git a/routing/router.go b/routing/router.go index 2aa5745e916..bb03143523b 100644 --- a/routing/router.go +++ b/routing/router.go @@ -967,6 +967,8 @@ func spewPayment(payment *LightningPayment) lnutils.LogClosure { func (r *ChannelRouter) PreparePayment(payment *LightningPayment) ( PaymentSession, shards.ShardTracker, error) { + ctx := context.TODO() + // Assemble any custom data we want to send to the first hop only. var firstHopData fn.Option[tlv.Blob] if len(payment.FirstHopCustomRecords) > 0 { @@ -1026,7 +1028,7 @@ func (r *ChannelRouter) PreparePayment(payment *LightningPayment) ( ) } - err = r.cfg.Control.InitPayment(payment.Identifier(), info) + err = r.cfg.Control.InitPayment(ctx, payment.Identifier(), info) if err != nil { return nil, nil, err } @@ -1131,7 +1133,7 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, FirstHopCustomRecords: firstHopCustomRecords, } - err := r.cfg.Control.InitPayment(paymentIdentifier, info) + err := r.cfg.Control.InitPayment(ctx, paymentIdentifier, info) switch { // If this is an MPP attempt and the hash is already registered with // the database, we can go on to launch the shard. From 8bb7150dc291ad9fe1b8ee518e57a41e6db26495 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 11 Nov 2025 11:23:15 +0100 Subject: [PATCH 54/78] multi: thread context through RegisterAttempt method --- payments/db/interface.go | 3 +- payments/db/kv_store.go | 2 +- payments/db/kv_store_test.go | 2 +- payments/db/payment_test.go | 52 +++++++++++++++++++++++------------ payments/db/sql_store.go | 4 +-- routing/control_tower.go | 9 +++--- routing/control_tower_test.go | 28 +++++++++++++------ routing/mock_test.go | 8 +++--- routing/payment_lifecycle.go | 4 ++- 9 files changed, 70 insertions(+), 42 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 2d0335609d2..3af2dfb671b 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -75,7 +75,8 @@ type PaymentControl interface { // - Result: 1700 sats sent, exceeding the payment amount // The payment router/controller layer is responsible for ensuring // serialized access per payment hash. - RegisterAttempt(lntypes.Hash, *HTLCAttemptInfo) (*MPPayment, error) + RegisterAttempt(context.Context, lntypes.Hash, + *HTLCAttemptInfo) (*MPPayment, error) // SettleAttempt marks the given attempt settled with the preimage. If // this is a multi shard payment, this might implicitly mean the diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 81b257ce86f..5511bf8bc44 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -359,7 +359,7 @@ func deserializePaymentIndex(r io.Reader) (lntypes.Hash, error) { // RegisterAttempt atomically records the provided HTLCAttemptInfo to the // DB. -func (p *KVStore) RegisterAttempt(paymentHash lntypes.Hash, +func (p *KVStore) RegisterAttempt(_ context.Context, paymentHash lntypes.Hash, attempt *HTLCAttemptInfo) (*MPPayment, error) { // Serialize the information before opening the db transaction. diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index 6837134e7d4..0c51dccf59c 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -85,7 +85,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { t.Fatalf("unable to send htlc message: %v", err) } _, err = paymentDB.RegisterAttempt( - info.PaymentIdentifier, attempt, + ctx, info.PaymentIdentifier, attempt, ) if err != nil { t.Fatalf("unable to send htlc message: %v", err) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 4b2cbcbd82d..8e2c7a47c82 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -151,7 +151,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { require.NoError(t, err, "unable to send htlc message") // Register and fail the first attempt for all payments. - _, err = p.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = p.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to send htlc message") htlcFailure := HTLCFailUnreadable @@ -175,7 +175,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { require.NoError(t, err) attemptID++ - _, err = p.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = p.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to send htlc message") switch payments[i].status { @@ -592,7 +592,7 @@ func TestMPPRecordValidation(t *testing.T) { info.Value, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to send htlc message") // Now try to register a non-MPP attempt, which should fail. @@ -604,21 +604,27 @@ func TestMPPRecordValidation(t *testing.T) { attempt2.Route.FinalHop().MPP = nil - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt2, + ) require.ErrorIs(t, err, ErrMPPayment) // Try to register attempt one with a different payment address. attempt2.Route.FinalHop().MPP = record.NewMPP( info.Value, [32]byte{2}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt2, + ) require.ErrorIs(t, err, ErrMPPPaymentAddrMismatch) // Try registering one with a different total amount. attempt2.Route.FinalHop().MPP = record.NewMPP( info.Value/2, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt2, + ) require.ErrorIs(t, err, ErrMPPTotalAmountMismatch) // Create and init a new payment. This time we'll check that we cannot @@ -641,7 +647,9 @@ func TestMPPRecordValidation(t *testing.T) { require.NoError(t, err, "unable to send htlc message") attempt.Route.FinalHop().MPP = nil - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt, + ) require.NoError(t, err, "unable to send htlc message") // Attempt to register an MPP attempt, which should fail. @@ -655,7 +663,9 @@ func TestMPPRecordValidation(t *testing.T) { info.Value, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt2) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt2, + ) require.ErrorIs(t, err, ErrNonMPPayment) } @@ -1758,7 +1768,7 @@ func TestSwitchDoubleSend(t *testing.T) { require.ErrorIs(t, err, ErrPaymentExists) // Record an attempt. - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to send htlc message") assertDBPaymentstatus( t, paymentDB, info.PaymentIdentifier, StatusInFlight, @@ -1869,7 +1879,7 @@ func TestSwitchFail(t *testing.T) { // Record a new attempt. In this test scenario, the attempt fails. // However, this is not communicated to control tower in the current // implementation. It only registers the initiation of the attempt. - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to register attempt") htlcReason := HTLCFailUnreadable @@ -1899,7 +1909,7 @@ func TestSwitchFail(t *testing.T) { ) require.NoError(t, err) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, attempt) + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) require.NoError(t, err, "unable to send htlc message") assertDBPaymentstatus( t, paymentDB, info.PaymentIdentifier, StatusInFlight, @@ -2017,7 +2027,7 @@ func TestMultiShard(t *testing.T) { attempts = append(attempts, a) _, err = paymentDB.RegisterAttempt( - info.PaymentIdentifier, a, + ctx, info.PaymentIdentifier, a, ) if err != nil { t.Fatalf("unable to send htlc message: %v", err) @@ -2049,7 +2059,9 @@ func TestMultiShard(t *testing.T) { info.Value, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, b, + ) require.ErrorIs(t, err, ErrValueExceedsAmt) // Fail the second attempt. @@ -2156,7 +2168,9 @@ func TestMultiShard(t *testing.T) { info.Value, [32]byte{1}, ) - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, b, + ) if test.settleFirst { require.ErrorIs( t, err, ErrPaymentPendingSettled, @@ -2255,7 +2269,9 @@ func TestMultiShard(t *testing.T) { ) // Finally assert we cannot register more attempts. - _, err = paymentDB.RegisterAttempt(info.PaymentIdentifier, b) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, b, + ) require.ErrorIs(t, err, registerErr) } @@ -2658,7 +2674,7 @@ func TestQueryPayments(t *testing.T) { require.NoError(t, err) _, err = paymentDB.RegisterAttempt( - lastPaymentInfo.PaymentIdentifier, + ctx, lastPaymentInfo.PaymentIdentifier, &attempt.HTLCAttemptInfo, ) require.NoError(t, err) @@ -2842,7 +2858,7 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { require.NoError(t, err) _, err = paymentDB.RegisterAttempt( - info.PaymentIdentifier, attempt1, + ctx, info.PaymentIdentifier, attempt1, ) require.NoError(t, err) @@ -2850,7 +2866,7 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { require.NoError(t, err) _, err = paymentDB.RegisterAttempt( - info.PaymentIdentifier, attempt2, + ctx, info.PaymentIdentifier, attempt2, ) require.NoError(t, err) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index cacd5cdd6b7..8c963d360b3 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1496,11 +1496,9 @@ func (s *SQLStore) insertRouteHops(ctx context.Context, db SQLQueries, // the PaymentWriter interface and ultimately the DB interface. It represents // step 2 in the payment lifecycle control flow, called after InitPayment and // potentially multiple times for multi-path payments. -func (s *SQLStore) RegisterAttempt(paymentHash lntypes.Hash, +func (s *SQLStore) RegisterAttempt(ctx context.Context, paymentHash lntypes.Hash, attempt *HTLCAttemptInfo) (*MPPayment, error) { - ctx := context.TODO() - var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { diff --git a/routing/control_tower.go b/routing/control_tower.go index 8df87b473bf..28432d73224 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -31,7 +31,8 @@ type ControlTower interface { // RegisterAttempt atomically records the provided HTLCAttemptInfo. // // NOTE: Subscribers should be notified by the new state of the payment. - RegisterAttempt(lntypes.Hash, *paymentsdb.HTLCAttemptInfo) error + RegisterAttempt(context.Context, lntypes.Hash, + *paymentsdb.HTLCAttemptInfo) error // SettleAttempt marks the given attempt settled with the preimage. If // this is a multi shard payment, this might implicitly mean the the @@ -196,13 +197,13 @@ func (p *controlTower) DeleteFailedAttempts(paymentHash lntypes.Hash) error { // RegisterAttempt atomically records the provided HTLCAttemptInfo to the // DB. -func (p *controlTower) RegisterAttempt(paymentHash lntypes.Hash, - attempt *paymentsdb.HTLCAttemptInfo) error { +func (p *controlTower) RegisterAttempt(ctx context.Context, + paymentHash lntypes.Hash, attempt *paymentsdb.HTLCAttemptInfo) error { p.paymentsMtx.Lock(paymentHash) defer p.paymentsMtx.Unlock(paymentHash) - payment, err := p.db.RegisterAttempt(paymentHash, attempt) + payment, err := p.db.RegisterAttempt(ctx, paymentHash, attempt) if err != nil { return err } diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go index 0993fb2a688..20bdd17564f 100644 --- a/routing/control_tower_test.go +++ b/routing/control_tower_test.go @@ -92,7 +92,9 @@ func TestControlTowerSubscribeSuccess(t *testing.T) { require.NoError(t, err, "expected subscribe to succeed, but got") // Register an attempt. - err = pControl.RegisterAttempt(info.PaymentIdentifier, attempt) + err = pControl.RegisterAttempt( + t.Context(), info.PaymentIdentifier, attempt, + ) if err != nil { t.Fatal(err) } @@ -221,7 +223,9 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { require.NoError(t, err, "expected subscribe to succeed, but got: %v") // Register an attempt. - err = pControl.RegisterAttempt(info1.PaymentIdentifier, attempt1) + err = pControl.RegisterAttempt( + t.Context(), info1.PaymentIdentifier, attempt1, + ) require.NoError(t, err) // Initiate a second payment after the subscription is already active. @@ -232,7 +236,9 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { require.NoError(t, err) // Register an attempt on the second payment. - err = pControl.RegisterAttempt(info2.PaymentIdentifier, attempt2) + err = pControl.RegisterAttempt( + t.Context(), info2.PaymentIdentifier, attempt2, + ) require.NoError(t, err) // Mark the first payment as successful. @@ -341,7 +347,9 @@ func TestKVStoreSubscribeAllImmediate(t *testing.T) { require.NoError(t, err) // Register a payment update. - err = pControl.RegisterAttempt(info.PaymentIdentifier, attempt) + err = pControl.RegisterAttempt( + t.Context(), info.PaymentIdentifier, attempt, + ) require.NoError(t, err) subscription, err := pControl.SubscribeAllPayments() @@ -414,7 +422,9 @@ func TestKVStoreUnsubscribeSuccess(t *testing.T) { subscription1.Close() // Register a payment update. - err = pControl.RegisterAttempt(info.PaymentIdentifier, attempt) + err = pControl.RegisterAttempt( + t.Context(), info.PaymentIdentifier, attempt, + ) require.NoError(t, err) // Assert only subscription 2 receives the update. @@ -479,10 +489,10 @@ func testKVStoreSubscribeFail(t *testing.T, registerAttempt, // making any attempts at all. if registerAttempt { // Register an attempt. - err = pControl.RegisterAttempt(info.PaymentIdentifier, attempt) - if err != nil { - t.Fatal(err) - } + err = pControl.RegisterAttempt( + t.Context(), info.PaymentIdentifier, attempt, + ) + require.NoError(t, err) // Fail the payment attempt. failInfo := paymentsdb.HTLCFailInfo{ diff --git a/routing/mock_test.go b/routing/mock_test.go index 5b9d4854b13..daad344fdf8 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -354,8 +354,8 @@ func (m *mockControlTowerOld) DeleteFailedAttempts(phash lntypes.Hash) error { return nil } -func (m *mockControlTowerOld) RegisterAttempt(phash lntypes.Hash, - a *paymentsdb.HTLCAttemptInfo) error { +func (m *mockControlTowerOld) RegisterAttempt(_ context.Context, + phash lntypes.Hash, a *paymentsdb.HTLCAttemptInfo) error { if m.registerAttempt != nil { m.registerAttempt <- registerAttemptArgs{a} @@ -746,8 +746,8 @@ func (m *mockControlTower) DeleteFailedAttempts(phash lntypes.Hash) error { return args.Error(0) } -func (m *mockControlTower) RegisterAttempt(phash lntypes.Hash, - a *paymentsdb.HTLCAttemptInfo) error { +func (m *mockControlTower) RegisterAttempt(_ context.Context, + phash lntypes.Hash, a *paymentsdb.HTLCAttemptInfo) error { args := m.Called(phash, a) return args.Error(0) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 4eb78c8e22e..0499475416d 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -584,6 +584,8 @@ func (p *paymentLifecycle) collectResult( func (p *paymentLifecycle) registerAttempt(rt *route.Route, remainingAmt lnwire.MilliSatoshi) (*paymentsdb.HTLCAttempt, error) { + ctx := context.TODO() + // If this route will consume the last remaining amount to send // to the receiver, this will be our last shard (for now). isLastAttempt := rt.ReceiverAmt() == remainingAmt @@ -601,7 +603,7 @@ func (p *paymentLifecycle) registerAttempt(rt *route.Route, // Switch for its whereabouts. The route is needed to handle the result // when it eventually comes back. err = p.router.cfg.Control.RegisterAttempt( - p.identifier, &attempt.HTLCAttemptInfo, + ctx, p.identifier, &attempt.HTLCAttemptInfo, ) return attempt, err From 3b7fdabaecf7eeda62088dc03264de5767b80297 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 08:54:50 +0200 Subject: [PATCH 55/78] multi: thread context through SettleAttempt --- payments/db/interface.go | 3 ++- payments/db/kv_store.go | 2 +- payments/db/kv_store_test.go | 2 +- payments/db/payment_test.go | 15 ++++++++------- payments/db/sql_store.go | 9 ++++----- routing/control_tower.go | 15 +++++++++------ routing/control_tower_test.go | 9 ++++++--- routing/mock_test.go | 6 +++--- routing/payment_lifecycle.go | 4 +++- 9 files changed, 37 insertions(+), 28 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 3af2dfb671b..452a7e5f79a 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -86,7 +86,8 @@ type PaymentControl interface { // error to prevent us from making duplicate payments to the same // payment hash. The provided preimage is atomically saved to the DB // for record keeping. - SettleAttempt(lntypes.Hash, uint64, *HTLCSettleInfo) (*MPPayment, error) + SettleAttempt(context.Context, lntypes.Hash, uint64, + *HTLCSettleInfo) (*MPPayment, error) // FailAttempt marks the given payment attempt failed. FailAttempt(lntypes.Hash, uint64, *HTLCFailInfo) (*MPPayment, error) diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 5511bf8bc44..3739232639c 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -430,7 +430,7 @@ func (p *KVStore) RegisterAttempt(_ context.Context, paymentHash lntypes.Hash, // After invoking this method, InitPayment should always return an error to // prevent us from making duplicate payments to the same payment hash. The // provided preimage is atomically saved to the DB for record keeping. -func (p *KVStore) SettleAttempt(hash lntypes.Hash, +func (p *KVStore) SettleAttempt(_ context.Context, hash lntypes.Hash, attemptID uint64, settleInfo *HTLCSettleInfo) (*MPPayment, error) { var b bytes.Buffer diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index 0c51dccf59c..fb7a2757338 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -133,7 +133,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { case p.success: // Verifies that status was changed to StatusSucceeded. _, err := paymentDB.SettleAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCSettleInfo{ Preimage: preimg, }, diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 8e2c7a47c82..159f9709285 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -198,7 +198,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { // Settle the attempt case StatusSucceeded: _, err := p.SettleAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCSettleInfo{ Preimage: preimg, }, @@ -1643,6 +1643,7 @@ func TestSuccessesWithoutInFlight(t *testing.T) { // Attempt to complete the payment should fail. _, err = paymentDB.SettleAttempt( + t.Context(), info.PaymentIdentifier, 0, &HTLCSettleInfo{ Preimage: preimg, @@ -1790,7 +1791,7 @@ func TestSwitchDoubleSend(t *testing.T) { // After settling, the error should be ErrAlreadyPaid. _, err = paymentDB.SettleAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCSettleInfo{ Preimage: preimg, }, @@ -1926,7 +1927,7 @@ func TestSwitchFail(t *testing.T) { // Settle the attempt and verify that status was changed to // StatusSucceeded. payment, err = paymentDB.SettleAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCSettleInfo{ Preimage: preimg, }, @@ -2099,7 +2100,7 @@ func TestMultiShard(t *testing.T) { var firstFailReason *FailureReason if test.settleFirst { _, err := paymentDB.SettleAttempt( - info.PaymentIdentifier, a.AttemptID, + ctx, info.PaymentIdentifier, a.AttemptID, &HTLCSettleInfo{ Preimage: preimg, }, @@ -2193,7 +2194,7 @@ func TestMultiShard(t *testing.T) { if test.settleLast { // Settle the last outstanding attempt. _, err = paymentDB.SettleAttempt( - info.PaymentIdentifier, a.AttemptID, + ctx, info.PaymentIdentifier, a.AttemptID, &HTLCSettleInfo{ Preimage: preimg, }, @@ -2683,7 +2684,7 @@ func TestQueryPayments(t *testing.T) { copy(preimg[:], rev[:]) _, err = paymentDB.SettleAttempt( - lastPaymentInfo.PaymentIdentifier, + ctx, lastPaymentInfo.PaymentIdentifier, attempt.AttemptID, &HTLCSettleInfo{ Preimage: preimg, @@ -2813,7 +2814,7 @@ func TestFetchInFlightPayments(t *testing.T) { require.NoError(t, err) _, err = paymentDB.SettleAttempt( - payments[2].id, 5, + ctx, payments[2].id, 5, &HTLCSettleInfo{ Preimage: preimg, }, diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 8c963d360b3..ca5add1d156 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1496,8 +1496,9 @@ func (s *SQLStore) insertRouteHops(ctx context.Context, db SQLQueries, // the PaymentWriter interface and ultimately the DB interface. It represents // step 2 in the payment lifecycle control flow, called after InitPayment and // potentially multiple times for multi-path payments. -func (s *SQLStore) RegisterAttempt(ctx context.Context, paymentHash lntypes.Hash, - attempt *HTLCAttemptInfo) (*MPPayment, error) { +func (s *SQLStore) RegisterAttempt(ctx context.Context, + paymentHash lntypes.Hash, attempt *HTLCAttemptInfo) (*MPPayment, + error) { var mpPayment *MPPayment @@ -1621,11 +1622,9 @@ func (s *SQLStore) RegisterAttempt(ctx context.Context, paymentHash lntypes.Hash // the PaymentWriter interface and ultimately the DB interface. It represents // step 3a in the payment lifecycle control flow (step 3b is FailAttempt), // called after RegisterAttempt when an HTLC successfully completes. -func (s *SQLStore) SettleAttempt(paymentHash lntypes.Hash, +func (s *SQLStore) SettleAttempt(ctx context.Context, paymentHash lntypes.Hash, attemptID uint64, settleInfo *HTLCSettleInfo) (*MPPayment, error) { - ctx := context.TODO() - var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { diff --git a/routing/control_tower.go b/routing/control_tower.go index 28432d73224..163aec3e753 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -44,8 +44,8 @@ type ControlTower interface { // for record keeping. // // NOTE: Subscribers should be notified by the new state of the payment. - SettleAttempt(lntypes.Hash, uint64, *paymentsdb.HTLCSettleInfo) ( - *paymentsdb.HTLCAttempt, error) + SettleAttempt(context.Context, lntypes.Hash, uint64, + *paymentsdb.HTLCSettleInfo) (*paymentsdb.HTLCAttempt, error) // FailAttempt marks the given payment attempt failed. // @@ -217,14 +217,17 @@ func (p *controlTower) RegisterAttempt(ctx context.Context, // SettleAttempt marks the given attempt settled with the preimage. If // this is a multi shard payment, this might implicitly mean the the // full payment succeeded. -func (p *controlTower) SettleAttempt(paymentHash lntypes.Hash, - attemptID uint64, settleInfo *paymentsdb.HTLCSettleInfo) ( - *paymentsdb.HTLCAttempt, error) { +func (p *controlTower) SettleAttempt(ctx context.Context, + paymentHash lntypes.Hash, attemptID uint64, + settleInfo *paymentsdb.HTLCSettleInfo) (*paymentsdb.HTLCAttempt, + error) { p.paymentsMtx.Lock(paymentHash) defer p.paymentsMtx.Unlock(paymentHash) - payment, err := p.db.SettleAttempt(paymentHash, attemptID, settleInfo) + payment, err := p.db.SettleAttempt( + ctx, paymentHash, attemptID, settleInfo, + ) if err != nil { return nil, err } diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go index 20bdd17564f..20e8e82e42a 100644 --- a/routing/control_tower_test.go +++ b/routing/control_tower_test.go @@ -108,7 +108,8 @@ func TestControlTowerSubscribeSuccess(t *testing.T) { Preimage: preimg, } htlcAttempt, err := pControl.SettleAttempt( - info.PaymentIdentifier, attempt.AttemptID, &settleInfo, + t.Context(), info.PaymentIdentifier, attempt.AttemptID, + &settleInfo, ) if err != nil { t.Fatal(err) @@ -246,7 +247,8 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { Preimage: preimg1, } htlcAttempt1, err := pControl.SettleAttempt( - info1.PaymentIdentifier, attempt1.AttemptID, &settleInfo1, + t.Context(), info1.PaymentIdentifier, attempt1.AttemptID, + &settleInfo1, ) require.NoError(t, err) require.Equal( @@ -259,7 +261,8 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { Preimage: preimg2, } htlcAttempt2, err := pControl.SettleAttempt( - info2.PaymentIdentifier, attempt2.AttemptID, &settleInfo2, + t.Context(), info2.PaymentIdentifier, attempt2.AttemptID, + &settleInfo2, ) require.NoError(t, err) require.Equal( diff --git a/routing/mock_test.go b/routing/mock_test.go index daad344fdf8..77f98ab017d 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -408,8 +408,8 @@ func (m *mockControlTowerOld) RegisterAttempt(_ context.Context, return nil } -func (m *mockControlTowerOld) SettleAttempt(phash lntypes.Hash, - pid uint64, settleInfo *paymentsdb.HTLCSettleInfo) ( +func (m *mockControlTowerOld) SettleAttempt(_ context.Context, + phash lntypes.Hash, pid uint64, settleInfo *paymentsdb.HTLCSettleInfo) ( *paymentsdb.HTLCAttempt, error) { if m.settleAttempt != nil { @@ -753,7 +753,7 @@ func (m *mockControlTower) RegisterAttempt(_ context.Context, return args.Error(0) } -func (m *mockControlTower) SettleAttempt(phash lntypes.Hash, +func (m *mockControlTower) SettleAttempt(_ context.Context, phash lntypes.Hash, pid uint64, settleInfo *paymentsdb.HTLCSettleInfo) ( *paymentsdb.HTLCAttempt, error) { diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 0499475416d..a2e3935a0b1 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -1166,6 +1166,8 @@ func (p *paymentLifecycle) reloadPayment() (paymentsdb.DBMPPayment, func (p *paymentLifecycle) handleAttemptResult(attempt *paymentsdb.HTLCAttempt, result *htlcswitch.PaymentResult) (*attemptResult, error) { + ctx := context.TODO() + // If the result has an error, we need to further process it by failing // the attempt and maybe fail the payment. if result.Error != nil { @@ -1187,7 +1189,7 @@ func (p *paymentLifecycle) handleAttemptResult(attempt *paymentsdb.HTLCAttempt, // In case of success we atomically store settle result to the DB and // move the shard to the settled state. htlcAttempt, err := p.router.cfg.Control.SettleAttempt( - p.identifier, attempt.AttemptID, + ctx, p.identifier, attempt.AttemptID, &paymentsdb.HTLCSettleInfo{ Preimage: result.Preimage, SettleTime: p.router.cfg.Clock.Now(), From 446300984ec417a892c4991ad7c0bcf95b077207 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 09:02:22 +0200 Subject: [PATCH 56/78] multi: thread context through FailAttempt --- payments/db/interface.go | 3 ++- payments/db/kv_store.go | 2 +- payments/db/kv_store_test.go | 2 +- payments/db/payment_test.go | 12 ++++++------ payments/db/sql_store.go | 4 +--- routing/control_tower.go | 12 ++++++------ routing/control_tower_test.go | 6 ++++-- routing/mock_test.go | 10 ++++++---- routing/payment_lifecycle.go | 4 +++- routing/router.go | 4 +++- 10 files changed, 33 insertions(+), 26 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 452a7e5f79a..45d0e9a8a6b 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -90,7 +90,8 @@ type PaymentControl interface { *HTLCSettleInfo) (*MPPayment, error) // FailAttempt marks the given payment attempt failed. - FailAttempt(lntypes.Hash, uint64, *HTLCFailInfo) (*MPPayment, error) + FailAttempt(context.Context, lntypes.Hash, uint64, + *HTLCFailInfo) (*MPPayment, error) // Fail transitions a payment into the Failed state, and records // the ultimate reason the payment failed. Note that this should only diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 3739232639c..59fe24f36ed 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -443,7 +443,7 @@ func (p *KVStore) SettleAttempt(_ context.Context, hash lntypes.Hash, } // FailAttempt marks the given payment attempt failed. -func (p *KVStore) FailAttempt(hash lntypes.Hash, +func (p *KVStore) FailAttempt(_ context.Context, hash lntypes.Hash, attemptID uint64, failInfo *HTLCFailInfo) (*MPPayment, error) { var b bytes.Buffer diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index fb7a2757338..ee8412a6fb3 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -100,7 +100,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { // Fail the payment attempt. htlcFailure := HTLCFailUnreadable _, err := paymentDB.FailAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCFailInfo{ Reason: htlcFailure, }, diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 159f9709285..879dfb12448 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -156,7 +156,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { htlcFailure := HTLCFailUnreadable _, err = p.FailAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCFailInfo{ Reason: htlcFailure, }, @@ -183,7 +183,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { case StatusFailed: htlcFailure := HTLCFailUnreadable _, err = p.FailAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCFailInfo{ Reason: htlcFailure, }, @@ -1885,7 +1885,7 @@ func TestSwitchFail(t *testing.T) { htlcReason := HTLCFailUnreadable _, err = paymentDB.FailAttempt( - info.PaymentIdentifier, attempt.AttemptID, + ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCFailInfo{ Reason: htlcReason, }, @@ -2069,7 +2069,7 @@ func TestMultiShard(t *testing.T) { a := attempts[1] htlcFail := HTLCFailUnreadable _, err = paymentDB.FailAttempt( - info.PaymentIdentifier, a.AttemptID, + ctx, info.PaymentIdentifier, a.AttemptID, &HTLCFailInfo{ Reason: htlcFail, }, @@ -2118,7 +2118,7 @@ func TestMultiShard(t *testing.T) { ) } else { _, err := paymentDB.FailAttempt( - info.PaymentIdentifier, a.AttemptID, + ctx, info.PaymentIdentifier, a.AttemptID, &HTLCFailInfo{ Reason: htlcFail, }, @@ -2209,7 +2209,7 @@ func TestMultiShard(t *testing.T) { } else { // Fail the attempt. _, err := paymentDB.FailAttempt( - info.PaymentIdentifier, a.AttemptID, + ctx, info.PaymentIdentifier, a.AttemptID, &HTLCFailInfo{ Reason: htlcFail, }, diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index ca5add1d156..a921a12335b 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1695,11 +1695,9 @@ func (s *SQLStore) SettleAttempt(ctx context.Context, paymentHash lntypes.Hash, // the PaymentWriter interface and ultimately the DB interface. It represents // step 3b in the payment lifecycle control flow (step 3a is SettleAttempt), // called after RegisterAttempt when an HTLC fails. -func (s *SQLStore) FailAttempt(paymentHash lntypes.Hash, +func (s *SQLStore) FailAttempt(ctx context.Context, paymentHash lntypes.Hash, attemptID uint64, failInfo *HTLCFailInfo) (*MPPayment, error) { - ctx := context.TODO() - var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { diff --git a/routing/control_tower.go b/routing/control_tower.go index 163aec3e753..cbb79d4c79a 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -50,8 +50,8 @@ type ControlTower interface { // FailAttempt marks the given payment attempt failed. // // NOTE: Subscribers should be notified by the new state of the payment. - FailAttempt(lntypes.Hash, uint64, *paymentsdb.HTLCFailInfo) ( - *paymentsdb.HTLCAttempt, error) + FailAttempt(context.Context, lntypes.Hash, uint64, + *paymentsdb.HTLCFailInfo) (*paymentsdb.HTLCAttempt, error) // FetchPayment fetches the payment corresponding to the given payment // hash. @@ -239,14 +239,14 @@ func (p *controlTower) SettleAttempt(ctx context.Context, } // FailAttempt marks the given payment attempt failed. -func (p *controlTower) FailAttempt(paymentHash lntypes.Hash, - attemptID uint64, failInfo *paymentsdb.HTLCFailInfo) ( - *paymentsdb.HTLCAttempt, error) { +func (p *controlTower) FailAttempt(ctx context.Context, + paymentHash lntypes.Hash, attemptID uint64, + failInfo *paymentsdb.HTLCFailInfo) (*paymentsdb.HTLCAttempt, error) { p.paymentsMtx.Lock(paymentHash) defer p.paymentsMtx.Unlock(paymentHash) - payment, err := p.db.FailAttempt(paymentHash, attemptID, failInfo) + payment, err := p.db.FailAttempt(ctx, paymentHash, attemptID, failInfo) if err != nil { return nil, err } diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go index 20e8e82e42a..5241d814dff 100644 --- a/routing/control_tower_test.go +++ b/routing/control_tower_test.go @@ -448,7 +448,8 @@ func TestKVStoreUnsubscribeSuccess(t *testing.T) { Reason: paymentsdb.HTLCFailInternal, } _, err = pControl.FailAttempt( - info.PaymentIdentifier, attempt.AttemptID, &failInfo, + t.Context(), info.PaymentIdentifier, attempt.AttemptID, + &failInfo, ) require.NoError(t, err, "unable to fail htlc") @@ -502,7 +503,8 @@ func testKVStoreSubscribeFail(t *testing.T, registerAttempt, Reason: paymentsdb.HTLCFailInternal, } htlcAttempt, err := pControl.FailAttempt( - info.PaymentIdentifier, attempt.AttemptID, &failInfo, + t.Context(), info.PaymentIdentifier, attempt.AttemptID, + &failInfo, ) if err != nil { t.Fatalf("unable to fail htlc: %v", err) diff --git a/routing/mock_test.go b/routing/mock_test.go index 77f98ab017d..f10c38ad0d4 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -451,8 +451,9 @@ func (m *mockControlTowerOld) SettleAttempt(_ context.Context, return nil, fmt.Errorf("pid not found") } -func (m *mockControlTowerOld) FailAttempt(phash lntypes.Hash, pid uint64, - failInfo *paymentsdb.HTLCFailInfo) (*paymentsdb.HTLCAttempt, error) { +func (m *mockControlTowerOld) FailAttempt(_ context.Context, phash lntypes.Hash, + pid uint64, failInfo *paymentsdb.HTLCFailInfo) (*paymentsdb.HTLCAttempt, + error) { if m.failAttempt != nil { m.failAttempt <- failAttemptArgs{failInfo} @@ -767,8 +768,9 @@ func (m *mockControlTower) SettleAttempt(_ context.Context, phash lntypes.Hash, return attempt.(*paymentsdb.HTLCAttempt), args.Error(1) } -func (m *mockControlTower) FailAttempt(phash lntypes.Hash, pid uint64, - failInfo *paymentsdb.HTLCFailInfo) (*paymentsdb.HTLCAttempt, error) { +func (m *mockControlTower) FailAttempt(_ context.Context, phash lntypes.Hash, + pid uint64, failInfo *paymentsdb.HTLCFailInfo) (*paymentsdb.HTLCAttempt, + error) { args := m.Called(phash, pid, failInfo) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index a2e3935a0b1..904d399cd13 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -1003,6 +1003,8 @@ func (p *paymentLifecycle) handleFailureMessage(rt *route.Route, func (p *paymentLifecycle) failAttempt(attemptID uint64, sendError error) (*attemptResult, error) { + ctx := context.TODO() + log.Warnf("Attempt %v for payment %v failed: %v", attemptID, p.identifier, sendError) @@ -1019,7 +1021,7 @@ func (p *paymentLifecycle) failAttempt(attemptID uint64, } attempt, err := p.router.cfg.Control.FailAttempt( - p.identifier, attemptID, failInfo, + ctx, p.identifier, attemptID, failInfo, ) if err != nil { return nil, err diff --git a/routing/router.go b/routing/router.go index bb03143523b..93192609b11 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1531,6 +1531,8 @@ func (r *ChannelRouter) resumePayments() error { func (r *ChannelRouter) failStaleAttempt(a paymentsdb.HTLCAttempt, payHash lntypes.Hash) { + ctx := context.TODO() + // We can only fail inflight HTLCs so we skip the settled/failed ones. if a.Failure != nil || a.Settle != nil { return @@ -1614,7 +1616,7 @@ func (r *ChannelRouter) failStaleAttempt(a paymentsdb.HTLCAttempt, Reason: paymentsdb.HTLCFailUnknown, FailTime: r.cfg.Clock.Now(), } - _, err = r.cfg.Control.FailAttempt(payHash, a.AttemptID, failInfo) + _, err = r.cfg.Control.FailAttempt(ctx, payHash, a.AttemptID, failInfo) if err != nil { log.Errorf("Fail attempt=%v got error: %v", a.AttemptID, err) } From 635a67c94a18a76139705394d0c0f066142d6f01 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 10:11:32 +0200 Subject: [PATCH 57/78] multi: thread context through Fail payment functions --- payments/db/interface.go | 2 +- payments/db/kv_store.go | 2 +- payments/db/kv_store_test.go | 2 +- payments/db/payment_test.go | 13 +++++++------ payments/db/sql_store.go | 4 +--- routing/control_tower.go | 9 +++++---- routing/control_tower_test.go | 3 ++- routing/mock_test.go | 4 ++-- routing/payment_lifecycle.go | 21 ++++++++++++++++++--- routing/router.go | 4 +++- 10 files changed, 41 insertions(+), 23 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 45d0e9a8a6b..2d2c47b3b70 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -99,7 +99,7 @@ type PaymentControl interface { // invoking this method, InitPayment should return nil on its next call // for this payment hash, allowing the user to make a subsequent // payment. - Fail(lntypes.Hash, FailureReason) (*MPPayment, error) + Fail(context.Context, lntypes.Hash, FailureReason) (*MPPayment, error) // DeleteFailedAttempts removes all failed HTLCs from the db. It should // be called for a given payment whenever all inflight htlcs are diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 59fe24f36ed..285074b51e3 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -528,7 +528,7 @@ func (p *KVStore) updateHtlcKey(paymentHash lntypes.Hash, // payment failed. After invoking this method, InitPayment should return nil on // its next call for this payment hash, allowing the switch to make a // subsequent payment. -func (p *KVStore) Fail(paymentHash lntypes.Hash, +func (p *KVStore) Fail(_ context.Context, paymentHash lntypes.Hash, reason FailureReason) (*MPPayment, error) { var ( diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index ee8412a6fb3..de3fc4ad24f 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -112,7 +112,7 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { // Fail the payment, which should moved it to Failed. failReason := FailureReasonNoRoute _, err = paymentDB.Fail( - info.PaymentIdentifier, failReason, + ctx, info.PaymentIdentifier, failReason, ) if err != nil { t.Fatalf("unable to fail payment hash: %v", err) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 879dfb12448..5f1ab69c164 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -191,8 +191,9 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { require.NoError(t, err, "unable to fail htlc") failReason := FailureReasonNoRoute - _, err = p.Fail(info.PaymentIdentifier, - failReason) + _, err = p.Fail( + ctx, info.PaymentIdentifier, failReason, + ) require.NoError(t, err, "unable to fail payment hash") // Settle the attempt @@ -1667,7 +1668,7 @@ func TestFailsWithoutInFlight(t *testing.T) { // Calling Fail should return an error. _, err = paymentDB.Fail( - info.PaymentIdentifier, FailureReasonNoRoute, + t.Context(), info.PaymentIdentifier, FailureReasonNoRoute, ) require.ErrorIs(t, err, ErrPaymentNotInitiated) } @@ -1843,7 +1844,7 @@ func TestSwitchFail(t *testing.T) { // Fail the payment, which should moved it to Failed. failReason := FailureReasonNoRoute - _, err = paymentDB.Fail(info.PaymentIdentifier, failReason) + _, err = paymentDB.Fail(ctx, info.PaymentIdentifier, failReason) require.NoError(t, err, "unable to fail payment hash") // Verify the status is indeed Failed. @@ -2139,7 +2140,7 @@ func TestMultiShard(t *testing.T) { // a terminal state. failReason := FailureReasonNoRoute _, err = paymentDB.Fail( - info.PaymentIdentifier, failReason, + ctx, info.PaymentIdentifier, failReason, ) if err != nil { t.Fatalf("unable to fail payment hash: %v", err) @@ -2232,7 +2233,7 @@ func TestMultiShard(t *testing.T) { // syncing. failReason := FailureReasonPaymentDetails _, err = paymentDB.Fail( - info.PaymentIdentifier, failReason, + ctx, info.PaymentIdentifier, failReason, ) require.NoError(t, err, "unable to fail") } diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index a921a12335b..a9863b53e30 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1782,11 +1782,9 @@ func (s *SQLStore) FailAttempt(ctx context.Context, paymentHash lntypes.Hash, // This method is part of the PaymentControl interface, which is embedded in // the PaymentWriter interface and ultimately the DB interface. It represents // step 4 in the payment lifecycle control flow. -func (s *SQLStore) Fail(paymentHash lntypes.Hash, +func (s *SQLStore) Fail(ctx context.Context, paymentHash lntypes.Hash, reason FailureReason) (*MPPayment, error) { - ctx := context.TODO() - var mpPayment *MPPayment err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { diff --git a/routing/control_tower.go b/routing/control_tower.go index cbb79d4c79a..b39a378bd47 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -66,7 +66,8 @@ type ControlTower interface { // payment. // // NOTE: Subscribers should be notified by the new state of the payment. - FailPayment(lntypes.Hash, paymentsdb.FailureReason) error + FailPayment(context.Context, lntypes.Hash, + paymentsdb.FailureReason) error // FetchInFlightPayments returns all payments with status InFlight. FetchInFlightPayments(ctx context.Context) ([]*paymentsdb.MPPayment, @@ -272,13 +273,13 @@ func (p *controlTower) FetchPayment(ctx context.Context, // // NOTE: This method will overwrite the failure reason if the payment is already // failed. -func (p *controlTower) FailPayment(paymentHash lntypes.Hash, - reason paymentsdb.FailureReason) error { +func (p *controlTower) FailPayment(ctx context.Context, + paymentHash lntypes.Hash, reason paymentsdb.FailureReason) error { p.paymentsMtx.Lock(paymentHash) defer p.paymentsMtx.Unlock(paymentHash) - payment, err := p.db.Fail(paymentHash, reason) + payment, err := p.db.Fail(ctx, paymentHash, reason) if err != nil { return err } diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go index 5241d814dff..c9e8f485733 100644 --- a/routing/control_tower_test.go +++ b/routing/control_tower_test.go @@ -516,7 +516,8 @@ func testKVStoreSubscribeFail(t *testing.T, registerAttempt, // Mark the payment as failed. err = pControl.FailPayment( - info.PaymentIdentifier, paymentsdb.FailureReasonTimeout, + t.Context(), info.PaymentIdentifier, + paymentsdb.FailureReasonTimeout, ) if err != nil { t.Fatal(err) diff --git a/routing/mock_test.go b/routing/mock_test.go index f10c38ad0d4..e72b392496f 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -491,7 +491,7 @@ func (m *mockControlTowerOld) FailAttempt(_ context.Context, phash lntypes.Hash, return nil, fmt.Errorf("pid not found") } -func (m *mockControlTowerOld) FailPayment(phash lntypes.Hash, +func (m *mockControlTowerOld) FailPayment(_ context.Context, phash lntypes.Hash, reason paymentsdb.FailureReason) error { m.Lock() @@ -782,7 +782,7 @@ func (m *mockControlTower) FailAttempt(_ context.Context, phash lntypes.Hash, return attempt.(*paymentsdb.HTLCAttempt), args.Error(1) } -func (m *mockControlTower) FailPayment(phash lntypes.Hash, +func (m *mockControlTower) FailPayment(_ context.Context, phash lntypes.Hash, reason paymentsdb.FailureReason) error { args := m.Called(phash, reason) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 904d399cd13..37dbd1c8ab4 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -364,11 +364,18 @@ func (p *paymentLifecycle) checkContext(ctx context.Context) error { p.identifier.String()) } + // The context is already cancelled at this point, so we create + // a new context so the payment can successfully be marked as + // failed. + cleanupCtx := context.WithoutCancel(ctx) + // By marking the payment failed, depending on whether it has // inflight HTLCs or not, its status will now either be // `StatusInflight` or `StatusFailed`. In either case, no more // HTLCs will be attempted. - err := p.router.cfg.Control.FailPayment(p.identifier, reason) + err := p.router.cfg.Control.FailPayment( + cleanupCtx, p.identifier, reason, + ) if err != nil { return fmt.Errorf("FailPayment got %w", err) } @@ -389,6 +396,8 @@ func (p *paymentLifecycle) checkContext(ctx context.Context) error { func (p *paymentLifecycle) requestRoute( ps *paymentsdb.MPPaymentState) (*route.Route, error) { + ctx := context.TODO() + remainingFees := p.calcFeeBudget(ps.FeesPaid) // Query our payment session to construct a route. @@ -430,7 +439,9 @@ func (p *paymentLifecycle) requestRoute( log.Warnf("Marking payment %v permanently failed with no route: %v", p.identifier, failureCode) - err = p.router.cfg.Control.FailPayment(p.identifier, failureCode) + err = p.router.cfg.Control.FailPayment( + ctx, p.identifier, failureCode, + ) if err != nil { return nil, fmt.Errorf("FailPayment got: %w", err) } @@ -800,6 +811,8 @@ func (p *paymentLifecycle) failPaymentAndAttempt( attemptID uint64, reason *paymentsdb.FailureReason, sendErr error) (*attemptResult, error) { + ctx := context.TODO() + log.Errorf("Payment %v failed: final_outcome=%v, raw_err=%v", p.identifier, *reason, sendErr) @@ -808,7 +821,9 @@ func (p *paymentLifecycle) failPaymentAndAttempt( // NOTE: we must fail the payment first before failing the attempt. // Otherwise, once the attempt is marked as failed, another goroutine // might make another attempt while we are failing the payment. - err := p.router.cfg.Control.FailPayment(p.identifier, *reason) + err := p.router.cfg.Control.FailPayment( + ctx, p.identifier, *reason, + ) if err != nil { log.Errorf("Unable to fail payment: %v", err) return nil, err diff --git a/routing/router.go b/routing/router.go index 93192609b11..fe8d06797a8 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1088,7 +1088,9 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, return nil } - return r.cfg.Control.FailPayment(paymentIdentifier, reason) + return r.cfg.Control.FailPayment( + ctx, paymentIdentifier, reason, + ) } log.Debugf("SendToRoute for payment %v with skipTempErr=%v", From 7f2df4b0889d5e6974ba03aafeb7a29e52971da8 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 10:18:02 +0200 Subject: [PATCH 58/78] multi: thread context through DeleteFailedAttempts --- payments/db/interface.go | 2 +- payments/db/kv_store.go | 6 ++++-- payments/db/payment_test.go | 26 +++++++++++++++++++------- payments/db/sql_store.go | 4 ++-- routing/control_tower.go | 8 +++++--- routing/mock_test.go | 8 ++++++-- routing/payment_lifecycle.go | 8 +++++++- 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/payments/db/interface.go b/payments/db/interface.go index 2d2c47b3b70..6edaa7f45b5 100644 --- a/payments/db/interface.go +++ b/payments/db/interface.go @@ -104,7 +104,7 @@ type PaymentControl interface { // DeleteFailedAttempts removes all failed HTLCs from the db. It should // be called for a given payment whenever all inflight htlcs are // completed, and the payment has reached a final terminal state. - DeleteFailedAttempts(lntypes.Hash) error + DeleteFailedAttempts(context.Context, lntypes.Hash) error } // DBMPPayment is an interface that represents the payment state during a diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 285074b51e3..0ce0601e498 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -290,12 +290,14 @@ func (p *KVStore) InitPayment(_ context.Context, paymentHash lntypes.Hash, // DeleteFailedAttempts deletes all failed htlcs for a payment if configured // by the KVStore db. -func (p *KVStore) DeleteFailedAttempts(hash lntypes.Hash) error { +func (p *KVStore) DeleteFailedAttempts(ctx context.Context, + hash lntypes.Hash) error { + // TODO(ziggie): Refactor to not mix application logic with database // logic. This decision should be made in the application layer. if !p.keepFailedPaymentAttempts { const failedHtlcsOnly = true - err := p.DeletePayment(context.TODO(), hash, failedHtlcsOnly) + err := p.DeletePayment(ctx, hash, failedHtlcsOnly) if err != nil { return err } diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 5f1ab69c164..25aafbb5464 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -503,7 +503,9 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { // Calling DeleteFailedAttempts on a failed payment should delete all // HTLCs. - require.NoError(t, paymentDB.DeleteFailedAttempts(payments[0].id)) + require.NoError(t, paymentDB.DeleteFailedAttempts( + t.Context(), payments[0].id, + )) // Expect all HTLCs to be deleted if the config is set to delete them. if !keepFailedPaymentAttempts { @@ -518,11 +520,15 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { // operation are performed in general therefore we do NOT expect an // error in this case. if keepFailedPaymentAttempts { - require.NoError(t, paymentDB.DeleteFailedAttempts( - payments[1].id), + err := paymentDB.DeleteFailedAttempts( + t.Context(), payments[1].id, ) + require.NoError(t, err) } else { - require.Error(t, paymentDB.DeleteFailedAttempts(payments[1].id)) + err := paymentDB.DeleteFailedAttempts( + t.Context(), payments[1].id, + ) + require.Error(t, err) } // Since DeleteFailedAttempts returned an error, we should expect the @@ -530,7 +536,9 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { assertDBPayments(t, paymentDB, payments) // Cleaning up a successful payment should remove failed htlcs. - require.NoError(t, paymentDB.DeleteFailedAttempts(payments[2].id)) + require.NoError(t, paymentDB.DeleteFailedAttempts( + t.Context(), payments[2].id, + )) // Expect all HTLCs except for the settled one to be deleted if the // config is set to delete them. @@ -547,13 +555,17 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { // payments, if the control tower is configured to keep failed // HTLCs. require.NoError( - t, paymentDB.DeleteFailedAttempts(lntypes.ZeroHash), + t, paymentDB.DeleteFailedAttempts( + t.Context(), lntypes.ZeroHash, + ), ) } else { // Attempting to cleanup a non-existent payment returns an // error. require.Error( - t, paymentDB.DeleteFailedAttempts(lntypes.ZeroHash), + t, paymentDB.DeleteFailedAttempts( + t.Context(), lntypes.ZeroHash, + ), ) } } diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index a9863b53e30..d23e80895f9 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1106,8 +1106,8 @@ func (s *SQLStore) FetchInFlightPayments(ctx context.Context) ([]*MPPayment, // the final step (step 5) in the payment lifecycle control flow and should be // called after a payment reaches a terminal state (succeeded or permanently // failed) to clean up historical failed attempts. -func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { - ctx := context.TODO() +func (s *SQLStore) DeleteFailedAttempts(ctx context.Context, + paymentHash lntypes.Hash) error { // In case we are configured to keep failed payment attempts, we exit // early. diff --git a/routing/control_tower.go b/routing/control_tower.go index b39a378bd47..1c246f17d9b 100644 --- a/routing/control_tower.go +++ b/routing/control_tower.go @@ -26,7 +26,7 @@ type ControlTower interface { // DeleteFailedAttempts removes all failed HTLCs from the db. It should // be called for a given payment whenever all inflight htlcs are // completed, and the payment has reached a final settled state. - DeleteFailedAttempts(lntypes.Hash) error + DeleteFailedAttempts(context.Context, lntypes.Hash) error // RegisterAttempt atomically records the provided HTLCAttemptInfo. // @@ -192,8 +192,10 @@ func (p *controlTower) InitPayment(ctx context.Context, // DeleteFailedAttempts deletes all failed htlcs if the payment was // successfully settled. -func (p *controlTower) DeleteFailedAttempts(paymentHash lntypes.Hash) error { - return p.db.DeleteFailedAttempts(paymentHash) +func (p *controlTower) DeleteFailedAttempts(ctx context.Context, + paymentHash lntypes.Hash) error { + + return p.db.DeleteFailedAttempts(ctx, paymentHash) } // RegisterAttempt atomically records the provided HTLCAttemptInfo to the diff --git a/routing/mock_test.go b/routing/mock_test.go index e72b392496f..472f1261623 100644 --- a/routing/mock_test.go +++ b/routing/mock_test.go @@ -328,7 +328,9 @@ func (m *mockControlTowerOld) InitPayment(_ context.Context, return nil } -func (m *mockControlTowerOld) DeleteFailedAttempts(phash lntypes.Hash) error { +func (m *mockControlTowerOld) DeleteFailedAttempts(_ context.Context, + phash lntypes.Hash) error { + p, ok := m.payments[phash] if !ok { return paymentsdb.ErrPaymentNotInitiated @@ -742,7 +744,9 @@ func (m *mockControlTower) InitPayment(_ context.Context, phash lntypes.Hash, return args.Error(0) } -func (m *mockControlTower) DeleteFailedAttempts(phash lntypes.Hash) error { +func (m *mockControlTower) DeleteFailedAttempts(_ context.Context, + phash lntypes.Hash) error { + args := m.Called(phash) return args.Error(0) } diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 37dbd1c8ab4..6405e850687 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -190,6 +190,10 @@ func (p *paymentLifecycle) decideNextStep( func (p *paymentLifecycle) resumePayment(ctx context.Context) ([32]byte, *route.Route, error) { + // We need to make sure we can still do db operations after the context + // is cancelled. + cleanupCtx := context.WithoutCancel(ctx) + // When the payment lifecycle loop exits, we make sure to signal any // sub goroutine of the HTLC attempt to exit, then wait for them to // return. @@ -328,7 +332,9 @@ lifecycle: // Optionally delete the failed attempts from the database. Depends on // the database options deleting attempts is not allowed so this will // just be a no-op. - err = p.router.cfg.Control.DeleteFailedAttempts(p.identifier) + err = p.router.cfg.Control.DeleteFailedAttempts( + cleanupCtx, p.identifier, + ) if err != nil { log.Errorf("Error deleting failed htlc attempts for payment "+ "%v: %v", p.identifier, err) From 33597ad35d0f3a327608b31087f74103fa81e71e Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 10:42:44 +0200 Subject: [PATCH 59/78] docs: add release notes --- docs/release-notes/release-notes-0.21.0.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index fa74f5dfbce..8a88b28f9bc 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -211,6 +211,9 @@ functions](https://github.com/lightningnetwork/lnd/pull/10368) * Finalize SQL payments implementation [enabling unit and itests for SQL backend](https://github.com/lightningnetwork/lnd/pull/10292) + * [Thread context through payment + db functions Part 1](https://github.com/lightningnetwork/lnd/pull/10307) + ## Code Health From dfe0c43c7d9363a25a7fd4ed6dabee1617a9e41b Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 10:52:30 +0200 Subject: [PATCH 60/78] routing: Add context to requestRoute --- routing/payment_lifecycle.go | 6 ++---- routing/payment_lifecycle_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 6405e850687..c8a59a32331 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -288,7 +288,7 @@ lifecycle: } // Now request a route to be used to create our HTLC attempt. - rt, err := p.requestRoute(ps) + rt, err := p.requestRoute(cleanupCtx, ps) if err != nil { return exitWithErr(err) } @@ -399,11 +399,9 @@ func (p *paymentLifecycle) checkContext(ctx context.Context) error { // requestRoute is responsible for finding a route to be used to create an HTLC // attempt. -func (p *paymentLifecycle) requestRoute( +func (p *paymentLifecycle) requestRoute(ctx context.Context, ps *paymentsdb.MPPaymentState) (*route.Route, error) { - ctx := context.TODO() - remainingFees := p.calcFeeBudget(ps.FeesPaid) // Query our payment session to construct a route. diff --git a/routing/payment_lifecycle_test.go b/routing/payment_lifecycle_test.go index 7e94315a7dc..a03218bfed8 100644 --- a/routing/payment_lifecycle_test.go +++ b/routing/payment_lifecycle_test.go @@ -393,7 +393,7 @@ func TestRequestRouteSucceed(t *testing.T) { mock.Anything, ).Return(dummyRoute, nil) - result, err := p.requestRoute(ps) + result, err := p.requestRoute(t.Context(), ps) require.NoError(t, err, "expect no error") require.Equal(t, dummyRoute, result, "returned route not matched") @@ -430,7 +430,7 @@ func TestRequestRouteHandleCriticalErr(t *testing.T) { mock.Anything, ).Return(nil, errDummy) - result, err := p.requestRoute(ps) + result, err := p.requestRoute(t.Context(), ps) // Expect an error is returned since it's critical. require.ErrorIs(t, err, errDummy, "error not matched") @@ -470,7 +470,7 @@ func TestRequestRouteHandleNoRouteErr(t *testing.T) { p.identifier, paymentsdb.FailureReasonNoRoute, ).Return(nil).Once() - result, err := p.requestRoute(ps) + result, err := p.requestRoute(t.Context(), ps) // Expect no error is returned since it's not critical. require.NoError(t, err, "expected no error") @@ -513,7 +513,7 @@ func TestRequestRouteFailPaymentError(t *testing.T) { mock.Anything, ).Return(nil, errNoTlvPayload) - result, err := p.requestRoute(ps) + result, err := p.requestRoute(t.Context(), ps) // Expect an error is returned. require.ErrorIs(t, err, errDummy, "error not matched") From 8038dc0054fbb87f02788185a72cd4078f09b9a7 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 11:30:07 +0200 Subject: [PATCH 61/78] multi: thread context through payment lifecyle functions --- lnrpc/routerrpc/router_server.go | 4 ++-- routing/payment_lifecycle.go | 30 +++++++++++++++----------- routing/payment_lifecycle_test.go | 32 +++++++++++++++------------- routing/router.go | 16 +++++++++----- routing/router_test.go | 28 +++++++++++++++++-------- rpcserver.go | 35 ++++++++++++++++++++----------- 6 files changed, 91 insertions(+), 54 deletions(-) diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go index a4031b154eb..7f2514aee51 100644 --- a/lnrpc/routerrpc/router_server.go +++ b/lnrpc/routerrpc/router_server.go @@ -1088,11 +1088,11 @@ func (s *Server) SendToRouteV2(ctx context.Context, // db. if req.SkipTempErr { attempt, err = s.cfg.Router.SendToRouteSkipTempErr( - hash, route, firstHopRecords, + ctx, hash, route, firstHopRecords, ) } else { attempt, err = s.cfg.Router.SendToRoute( - hash, route, firstHopRecords, + ctx, hash, route, firstHopRecords, ) } if attempt != nil { diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index c8a59a32331..43d56331dde 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -128,7 +128,7 @@ const ( // results is sent back. then process its result here. When there's no need to // wait for results, the method will exit with `stepExit` such that the payment // lifecycle loop will terminate. -func (p *paymentLifecycle) decideNextStep( +func (p *paymentLifecycle) decideNextStep(ctx context.Context, payment paymentsdb.DBMPPayment) (stateStep, error) { // Check whether we could make new HTLC attempts. @@ -168,7 +168,7 @@ func (p *paymentLifecycle) decideNextStep( // stepSkip and move to the next lifecycle iteration, which will // refresh the payment and wait for the next attempt result, if // any. - _, err := p.handleAttemptResult(r.attempt, r.result) + _, err := p.handleAttemptResult(ctx, r.attempt, r.result) // We would only get a DB-related error here, which will cause // us to abort the payment flow. @@ -192,6 +192,13 @@ func (p *paymentLifecycle) resumePayment(ctx context.Context) ([32]byte, // We need to make sure we can still do db operations after the context // is cancelled. + // + // TODO(ziggie): This is a workaround to avoid a greater refactor of the + // payment lifecycle. We can currently not rely on the parent context + // because this method is also collecting the results of inflight HTLCs + // after the context is cancelled. So we need to make sure we only use + // the current context to stop creating new attempts but use this + // cleanupCtx to do all the db operations. cleanupCtx := context.WithoutCancel(ctx) // When the payment lifecycle loop exits, we make sure to signal any @@ -264,7 +271,7 @@ lifecycle: // // Now decide the next step of the current lifecycle. - step, err := p.decideNextStep(payment) + step, err := p.decideNextStep(cleanupCtx, payment) if err != nil { return exitWithErr(err) } @@ -307,7 +314,9 @@ lifecycle: log.Tracef("Found route: %s", lnutils.SpewLogClosure(rt.Hops)) // We found a route to try, create a new HTLC attempt to try. - attempt, err := p.registerAttempt(rt, ps.RemainingAmt) + attempt, err := p.registerAttempt( + cleanupCtx, rt, ps.RemainingAmt, + ) if err != nil { return exitWithErr(err) } @@ -596,11 +605,9 @@ func (p *paymentLifecycle) collectResult( // registerAttempt is responsible for creating and saving an HTLC attempt in db // by using the route info provided. The `remainingAmt` is used to decide // whether this is the last attempt. -func (p *paymentLifecycle) registerAttempt(rt *route.Route, +func (p *paymentLifecycle) registerAttempt(ctx context.Context, rt *route.Route, remainingAmt lnwire.MilliSatoshi) (*paymentsdb.HTLCAttempt, error) { - ctx := context.TODO() - // If this route will consume the last remaining amount to send // to the receiver, this will be our last shard (for now). isLastAttempt := rt.ReceiverAmt() == remainingAmt @@ -1184,11 +1191,10 @@ func (p *paymentLifecycle) reloadPayment() (paymentsdb.DBMPPayment, // handleAttemptResult processes the result of an HTLC attempt returned from // the htlcswitch. -func (p *paymentLifecycle) handleAttemptResult(attempt *paymentsdb.HTLCAttempt, +func (p *paymentLifecycle) handleAttemptResult(ctx context.Context, + attempt *paymentsdb.HTLCAttempt, result *htlcswitch.PaymentResult) (*attemptResult, error) { - ctx := context.TODO() - // If the result has an error, we need to further process it by failing // the attempt and maybe fail the payment. if result.Error != nil { @@ -1235,7 +1241,7 @@ func (p *paymentLifecycle) handleAttemptResult(attempt *paymentsdb.HTLCAttempt, // available from the Switch, then records the attempt outcome with the control // tower. An attemptResult is returned, indicating the final outcome of this // HTLC attempt. -func (p *paymentLifecycle) collectAndHandleResult( +func (p *paymentLifecycle) collectAndHandleResult(ctx context.Context, attempt *paymentsdb.HTLCAttempt) (*attemptResult, error) { result, err := p.collectResult(attempt) @@ -1243,5 +1249,5 @@ func (p *paymentLifecycle) collectAndHandleResult( return nil, err } - return p.handleAttemptResult(attempt, result) + return p.handleAttemptResult(ctx, attempt, result) } diff --git a/routing/payment_lifecycle_test.go b/routing/payment_lifecycle_test.go index a03218bfed8..61ae83a31fc 100644 --- a/routing/payment_lifecycle_test.go +++ b/routing/payment_lifecycle_test.go @@ -599,7 +599,7 @@ func TestDecideNextStep(t *testing.T) { // Once the setup is finished, run the test cases. t.Run(tc.name, func(t *testing.T) { - step, err := p.decideNextStep(payment) + step, err := p.decideNextStep(t.Context(), payment) require.Equal(t, tc.expectedStep, step) require.ErrorIs(t, tc.expectedErr, err) }) @@ -628,7 +628,7 @@ func TestDecideNextStepOnRouterQuit(t *testing.T) { close(p.router.quit) // Call the method under test. - step, err := p.decideNextStep(payment) + step, err := p.decideNextStep(t.Context(), payment) // We expect stepExit and an error to be returned. require.Equal(t, stepExit, step) @@ -657,7 +657,7 @@ func TestDecideNextStepOnLifecycleQuit(t *testing.T) { close(p.quit) // Call the method under test. - step, err := p.decideNextStep(payment) + step, err := p.decideNextStep(t.Context(), payment) // We expect stepExit and an error to be returned. require.Equal(t, stepExit, step) @@ -716,7 +716,7 @@ func TestDecideNextStepHandleAttemptResultSucceed(t *testing.T) { mock.Anything).Return(attempt, nil).Once() // Call the method under test. - step, err := p.decideNextStep(payment) + step, err := p.decideNextStep(t.Context(), payment) // We expect stepSkip and no error to be returned. require.Equal(t, stepSkip, step) @@ -774,7 +774,7 @@ func TestDecideNextStepHandleAttemptResultFail(t *testing.T) { mock.Anything).Return(attempt, errDummy).Once() // Call the method under test. - step, err := p.decideNextStep(payment) + step, err := p.decideNextStep(t.Context(), payment) // We expect stepExit and the above error to be returned. require.Equal(t, stepExit, step) @@ -1467,7 +1467,7 @@ func TestCollectResultExitOnErr(t *testing.T) { m.clock.On("Now").Return(time.Now()) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.ErrorIs(t, err, errDummy, "expected dummy error") require.Nil(t, result, "expected nil attempt") } @@ -1513,7 +1513,7 @@ func TestCollectResultExitOnResultErr(t *testing.T) { m.clock.On("Now").Return(time.Now()) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.ErrorIs(t, err, errDummy, "expected dummy error") require.Nil(t, result, "expected nil attempt") } @@ -1539,7 +1539,7 @@ func TestCollectResultExitOnSwitchQuit(t *testing.T) { }) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.ErrorIs(t, err, htlcswitch.ErrSwitchExiting, "expected switch exit") require.Nil(t, result, "expected nil attempt") @@ -1566,7 +1566,7 @@ func TestCollectResultExitOnRouterQuit(t *testing.T) { }) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.ErrorIs(t, err, ErrRouterShuttingDown, "expected router exit") require.Nil(t, result, "expected nil attempt") } @@ -1592,7 +1592,7 @@ func TestCollectResultExitOnLifecycleQuit(t *testing.T) { }) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.ErrorIs(t, err, ErrPaymentLifecycleExiting, "expected lifecycle exit") require.Nil(t, result, "expected nil attempt") @@ -1636,7 +1636,7 @@ func TestCollectResultExitOnSettleErr(t *testing.T) { m.clock.On("Now").Return(time.Now()) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.ErrorIs(t, err, errDummy, "expected settle error") require.Nil(t, result, "expected nil attempt") } @@ -1678,7 +1678,7 @@ func TestCollectResultSuccess(t *testing.T) { m.clock.On("Now").Return(time.Now()) // Now call the method under test. - result, err := p.collectAndHandleResult(attempt) + result, err := p.collectAndHandleResult(t.Context(), attempt) require.NoError(t, err, "expected no error") require.Equal(t, preimage, result.attempt.Settle.Preimage, "preimage mismatch") @@ -1762,7 +1762,9 @@ func TestHandleAttemptResultWithError(t *testing.T) { // Call the method under test and expect the dummy error to be // returned. - attemptResult, err := p.handleAttemptResult(attempt, result) + attemptResult, err := p.handleAttemptResult( + t.Context(), attempt, result, + ) require.ErrorIs(t, err, errDummy, "expected fail error") require.Nil(t, attemptResult, "expected nil attempt result") } @@ -1800,7 +1802,9 @@ func TestHandleAttemptResultSuccess(t *testing.T) { // Call the method under test and expect the dummy error to be // returned. - attemptResult, err := p.handleAttemptResult(attempt, result) + attemptResult, err := p.handleAttemptResult( + t.Context(), attempt, result, + ) require.NoError(t, err, "expected no error") require.Equal(t, attempt, attemptResult.attempt) } diff --git a/routing/router.go b/routing/router.go index fe8d06797a8..c67fe42e340 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1038,7 +1038,8 @@ func (r *ChannelRouter) PreparePayment(payment *LightningPayment) ( // SendToRoute sends a payment using the provided route and fails the payment // when an error is returned from the attempt. -func (r *ChannelRouter) SendToRoute(htlcHash lntypes.Hash, rt *route.Route, +func (r *ChannelRouter) SendToRoute(_ context.Context, htlcHash lntypes.Hash, + rt *route.Route, firstHopCustomRecords lnwire.CustomRecords) (*paymentsdb.HTLCAttempt, error) { @@ -1047,8 +1048,8 @@ func (r *ChannelRouter) SendToRoute(htlcHash lntypes.Hash, rt *route.Route, // SendToRouteSkipTempErr sends a payment using the provided route and fails // the payment ONLY when a terminal error is returned from the attempt. -func (r *ChannelRouter) SendToRouteSkipTempErr(htlcHash lntypes.Hash, - rt *route.Route, +func (r *ChannelRouter) SendToRouteSkipTempErr(_ context.Context, + htlcHash lntypes.Hash, rt *route.Route, firstHopCustomRecords lnwire.CustomRecords) (*paymentsdb.HTLCAttempt, error) { @@ -1066,6 +1067,11 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, firstHopCustomRecords lnwire.CustomRecords) (*paymentsdb.HTLCAttempt, error) { + // TODO(ziggie): We cannot easily thread the context from the caller + // of this method because the payment lifecycle depends on the context + // to update the db. The Sending and Receiving of results is currently + // not cleanly separated which is the reason that we cannot easily + // cancel the context and therefore cancel the ongoing payment. ctx := context.TODO() // Helper function to fail a payment. It makes sure the payment is only @@ -1179,7 +1185,7 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, // NOTE: we use zero `remainingAmt` here to simulate the same effect of // setting the lastShard to be false, which is used by previous // implementation. - attempt, err := p.registerAttempt(rt, 0) + attempt, err := p.registerAttempt(ctx, rt, 0) if err != nil { return nil, err } @@ -1216,7 +1222,7 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, // The attempt was successfully sent, wait for the result to be // available. - result, err = p.collectAndHandleResult(attempt) + result, err = p.collectAndHandleResult(ctx, attempt) if err != nil { return nil, err } diff --git a/routing/router_test.go b/routing/router_test.go index 733943272d9..9bc7bdbe11c 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -522,7 +522,7 @@ func TestChannelUpdateValidation(t *testing.T) { // Send off the payment request to the router. The specified route // should be attempted and the channel update should be received by // graph and ignored because it is missing a valid signature. - _, err = ctx.router.SendToRoute(payment, rt, nil) + _, err = ctx.router.SendToRoute(t.Context(), payment, rt, nil) require.Error(t, err, "expected route to fail with channel update") _, e1, e2, err = ctx.graph.FetchChannelEdgesByID( @@ -542,7 +542,7 @@ func TestChannelUpdateValidation(t *testing.T) { ctx.graphBuilder.setNextReject(false) // Retry the payment using the same route as before. - _, err = ctx.router.SendToRoute(payment, rt, nil) + _, err = ctx.router.SendToRoute(t.Context(), payment, rt, nil) require.Error(t, err, "expected route to fail with channel update") // This time a valid signature was supplied and the policy change should @@ -1427,7 +1427,9 @@ func TestSendToRouteStructuredError(t *testing.T) { // update should be received by router and ignored // because it is missing a valid // signature. - _, err = ctx.router.SendToRoute(payment, rt, nil) + _, err = ctx.router.SendToRoute( + t.Context(), payment, rt, nil, + ) fErr, ok := err.(*htlcswitch.ForwardingError) require.True( @@ -1506,7 +1508,7 @@ func TestSendToRouteMaxHops(t *testing.T) { // Send off the payment request to the router. We expect an error back // indicating that the route is too long. var payHash lntypes.Hash - _, err = ctx.router.SendToRoute(payHash, rt, nil) + _, err = ctx.router.SendToRoute(t.Context(), payHash, rt, nil) if err != route.ErrMaxRouteHopsExceeded { t.Fatalf("expected ErrMaxRouteHopsExceeded, but got %v", err) } @@ -2221,7 +2223,9 @@ func TestSendToRouteSkipTempErrSuccess(t *testing.T) { ).Return(nil) // Expect a successful send to route. - attempt, err := router.SendToRouteSkipTempErr(payHash, rt, nil) + attempt, err := router.SendToRouteSkipTempErr( + t.Context(), payHash, rt, nil, + ) require.NoError(t, err) require.Equal(t, testAttempt, attempt) @@ -2276,7 +2280,9 @@ func TestSendToRouteSkipTempErrNonMPP(t *testing.T) { }} // Expect an error to be returned. - attempt, err := router.SendToRouteSkipTempErr(payHash, rt, nil) + attempt, err := router.SendToRouteSkipTempErr( + t.Context(), payHash, rt, nil, + ) require.ErrorIs(t, ErrSkipTempErr, err) require.Nil(t, attempt) @@ -2356,7 +2362,9 @@ func TestSendToRouteSkipTempErrTempFailure(t *testing.T) { ).Return(nil, nil) // Expect a failed send to route. - attempt, err := router.SendToRouteSkipTempErr(payHash, rt, nil) + attempt, err := router.SendToRouteSkipTempErr( + t.Context(), payHash, rt, nil, + ) require.Equal(t, tempErr, err) require.Equal(t, testAttempt, attempt) @@ -2440,7 +2448,9 @@ func TestSendToRouteSkipTempErrPermanentFailure(t *testing.T) { ).Return(&failureReason, nil) // Expect a failed send to route. - attempt, err := router.SendToRouteSkipTempErr(payHash, rt, nil) + attempt, err := router.SendToRouteSkipTempErr( + t.Context(), payHash, rt, nil, + ) require.Equal(t, permErr, err) require.Equal(t, testAttempt, attempt) @@ -2529,7 +2539,7 @@ func TestSendToRouteTempFailure(t *testing.T) { ).Return(nil, nil) // Expect a failed send to route. - attempt, err := router.SendToRoute(payHash, rt, nil) + attempt, err := router.SendToRoute(t.Context(), payHash, rt, nil) require.Equal(t, tempErr, err) require.Equal(t, testAttempt, attempt) diff --git a/rpcserver.go b/rpcserver.go index 2f88b2db83a..3909fb1a7f8 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -5581,8 +5581,9 @@ func (r *rpcServer) SubscribeChannelEvents(req *lnrpc.ChannelEventSubscription, // execute sendPayment. We use this struct as a sort of bridge to enable code // re-use between SendPayment and SendToRoute. type paymentStream struct { - recv func() (*rpcPaymentRequest, error) - send func(*lnrpc.SendResponse) error + getCtx func() context.Context + recv func() (*rpcPaymentRequest, error) + send func(*lnrpc.SendResponse) error } // rpcPaymentRequest wraps lnrpc.SendRequest so that routes from @@ -5596,10 +5597,13 @@ type rpcPaymentRequest struct { // through the Lightning Network. A single RPC invocation creates a persistent // bi-directional stream allowing clients to rapidly send payments through the // Lightning Network with a single persistent connection. -func (r *rpcServer) SendPayment(stream lnrpc.Lightning_SendPaymentServer) error { +func (r *rpcServer) SendPayment( + stream lnrpc.Lightning_SendPaymentServer) error { + var lock sync.Mutex return r.sendPayment(&paymentStream{ + getCtx: stream.Context, recv: func() (*rpcPaymentRequest, error) { req, err := stream.Recv() if err != nil { @@ -5624,10 +5628,13 @@ func (r *rpcServer) SendPayment(stream lnrpc.Lightning_SendPaymentServer) error // invocation creates a persistent bi-directional stream allowing clients to // rapidly send payments through the Lightning Network with a single persistent // connection. -func (r *rpcServer) SendToRoute(stream lnrpc.Lightning_SendToRouteServer) error { +func (r *rpcServer) SendToRoute( + stream lnrpc.Lightning_SendToRouteServer) error { + var lock sync.Mutex return r.sendPayment(&paymentStream{ + getCtx: stream.Context, recv: func() (*rpcPaymentRequest, error) { req, err := stream.Recv() if err != nil { @@ -5697,7 +5704,11 @@ type rpcPaymentIntent struct { // dispatch a client from the information presented by an RPC client. There are // three ways a client can specify their payment details: a payment request, // via manual details, or via a complete route. -func (r *rpcServer) extractPaymentIntent(rpcPayReq *rpcPaymentRequest) (rpcPaymentIntent, error) { +// +//nolint:funlen +func (r *rpcServer) extractPaymentIntent( + rpcPayReq *rpcPaymentRequest) (rpcPaymentIntent, error) { + payIntent := rpcPaymentIntent{} // If a route was specified, then we can use that directly. @@ -5969,7 +5980,7 @@ type paymentIntentResponse struct { // pre-built route. The first error this method returns denotes if we were // unable to save the payment. The second error returned denotes if the payment // didn't succeed. -func (r *rpcServer) dispatchPaymentIntent( +func (r *rpcServer) dispatchPaymentIntent(ctx context.Context, payIntent *rpcPaymentIntent) (*paymentIntentResponse, error) { // Construct a payment request to send to the channel router. If the @@ -6016,7 +6027,7 @@ func (r *rpcServer) dispatchPaymentIntent( } else { var attempt *paymentsdb.HTLCAttempt attempt, routerErr = r.server.chanRouter.SendToRoute( - payIntent.rHash, payIntent.route, nil, + ctx, payIntent.rHash, payIntent.route, nil, ) if routerErr == nil { @@ -6189,7 +6200,7 @@ sendLoop: }() resp, saveErr := r.dispatchPaymentIntent( - payIntent, + stream.getCtx(), payIntent, ) switch { @@ -6267,7 +6278,7 @@ sendLoop: func (r *rpcServer) SendPaymentSync(ctx context.Context, nextPayment *lnrpc.SendRequest) (*lnrpc.SendResponse, error) { - return r.sendPaymentSync(&rpcPaymentRequest{ + return r.sendPaymentSync(ctx, &rpcPaymentRequest{ SendRequest: nextPayment, }) } @@ -6288,12 +6299,12 @@ func (r *rpcServer) SendToRouteSync(ctx context.Context, return nil, err } - return r.sendPaymentSync(paymentRequest) + return r.sendPaymentSync(ctx, paymentRequest) } // sendPaymentSync is the synchronous variant of sendPayment. It will block and // wait until the payment has been fully completed. -func (r *rpcServer) sendPaymentSync( +func (r *rpcServer) sendPaymentSync(ctx context.Context, nextPayment *rpcPaymentRequest) (*lnrpc.SendResponse, error) { // We don't allow payments to be sent while the daemon itself is still @@ -6312,7 +6323,7 @@ func (r *rpcServer) sendPaymentSync( // With the payment validated, we'll now attempt to dispatch the // payment. - resp, saveErr := r.dispatchPaymentIntent(&payIntent) + resp, saveErr := r.dispatchPaymentIntent(ctx, &payIntent) switch { case saveErr != nil: return nil, saveErr From e130ccc494bd0fe249f6a095b08bcb896f477c98 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 11:39:39 +0200 Subject: [PATCH 62/78] routing: Thread context through failPaymentAndAttempt A context is added to failPaymentAndAttempt and its dependant function calls. --- routing/payment_lifecycle.go | 21 ++++++++++----------- routing/router.go | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 43d56331dde..b5df24379ef 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -322,7 +322,7 @@ lifecycle: } // Once the attempt is created, send it to the htlcswitch. - result, err := p.sendAttempt(attempt) + result, err := p.sendAttempt(cleanupCtx, attempt) if err != nil { return exitWithErr(err) } @@ -681,7 +681,7 @@ func (p *paymentLifecycle) createNewPaymentAttempt(rt *route.Route, // sendAttempt attempts to send the current attempt to the switch to complete // the payment. If this attempt fails, then we'll continue on to the next // available route. -func (p *paymentLifecycle) sendAttempt( +func (p *paymentLifecycle) sendAttempt(ctx context.Context, attempt *paymentsdb.HTLCAttempt) (*attemptResult, error) { log.Debugf("Sending HTLC attempt(id=%v, total_amt=%v, first_hop_amt=%d"+ @@ -727,7 +727,7 @@ func (p *paymentLifecycle) sendAttempt( log.Errorf("Failed sending attempt %d for payment %v to "+ "switch: %v", attempt.AttemptID, p.identifier, err) - return p.handleSwitchErr(attempt, err) + return p.handleSwitchErr(ctx, attempt, err) } log.Debugf("Attempt %v for payment %v successfully sent to switch, "+ @@ -818,12 +818,10 @@ func (p *paymentLifecycle) amendFirstHopData(rt *route.Route) error { // failAttemptAndPayment fails both the payment and its attempt via the // router's control tower, which marks the payment as failed in db. -func (p *paymentLifecycle) failPaymentAndAttempt( +func (p *paymentLifecycle) failPaymentAndAttempt(ctx context.Context, attemptID uint64, reason *paymentsdb.FailureReason, sendErr error) (*attemptResult, error) { - ctx := context.TODO() - log.Errorf("Payment %v failed: final_outcome=%v, raw_err=%v", p.identifier, *reason, sendErr) @@ -852,7 +850,8 @@ func (p *paymentLifecycle) failPaymentAndAttempt( // the error type, the error is either the final outcome of the payment or we // need to continue with an alternative route. A final outcome is indicated by // a non-nil reason value. -func (p *paymentLifecycle) handleSwitchErr(attempt *paymentsdb.HTLCAttempt, +func (p *paymentLifecycle) handleSwitchErr(ctx context.Context, + attempt *paymentsdb.HTLCAttempt, sendErr error) (*attemptResult, error) { internalErrorReason := paymentsdb.FailureReasonError @@ -883,7 +882,7 @@ func (p *paymentLifecycle) handleSwitchErr(attempt *paymentsdb.HTLCAttempt, } // Otherwise fail both the payment and the attempt. - return p.failPaymentAndAttempt(attemptID, reason, sendErr) + return p.failPaymentAndAttempt(ctx, attemptID, reason, sendErr) } // If this attempt ID is unknown to the Switch, it means it was never @@ -916,7 +915,7 @@ func (p *paymentLifecycle) handleSwitchErr(attempt *paymentsdb.HTLCAttempt, ok := errors.As(sendErr, &rtErr) if !ok { return p.failPaymentAndAttempt( - attemptID, &internalErrorReason, sendErr, + ctx, attemptID, &internalErrorReason, sendErr, ) } @@ -942,7 +941,7 @@ func (p *paymentLifecycle) handleSwitchErr(attempt *paymentsdb.HTLCAttempt, ) if err != nil { return p.failPaymentAndAttempt( - attemptID, &internalErrorReason, sendErr, + ctx, attemptID, &internalErrorReason, sendErr, ) } @@ -1198,7 +1197,7 @@ func (p *paymentLifecycle) handleAttemptResult(ctx context.Context, // If the result has an error, we need to further process it by failing // the attempt and maybe fail the payment. if result.Error != nil { - return p.handleSwitchErr(attempt, result.Error) + return p.handleSwitchErr(ctx, attempt, result.Error) } // We got an attempt settled result back from the switch. diff --git a/routing/router.go b/routing/router.go index c67fe42e340..71dcf7d8003 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1194,7 +1194,7 @@ func (r *ChannelRouter) sendToRoute(htlcHash lntypes.Hash, rt *route.Route, // the `err` returned here has already been processed by // `handleSwitchErr`, which means if there's a terminal failure, the // payment has been failed. - result, err := p.sendAttempt(attempt) + result, err := p.sendAttempt(ctx, attempt) if err != nil { return nil, err } From 5b06854850a75f83e244baa898ff636acd2a19ed Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 11:42:35 +0200 Subject: [PATCH 63/78] routing: add context to failAttempt --- routing/payment_lifecycle.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index b5df24379ef..c6f6154c105 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -713,7 +713,7 @@ func (p *paymentLifecycle) sendAttempt(ctx context.Context, "payment=%v, err:%v", attempt.AttemptID, p.identifier, err) - return p.failAttempt(attempt.AttemptID, err) + return p.failAttempt(ctx, attempt.AttemptID, err) } htlcAdd.OnionBlob = onionBlob @@ -839,7 +839,7 @@ func (p *paymentLifecycle) failPaymentAndAttempt(ctx context.Context, } // Fail the attempt. - return p.failAttempt(attemptID, sendErr) + return p.failAttempt(ctx, attemptID, sendErr) } // handleSwitchErr inspects the given error from the Switch and determines @@ -878,7 +878,7 @@ func (p *paymentLifecycle) handleSwitchErr(ctx context.Context, // Fail the attempt only if there's no reason. if reason == nil { // Fail the attempt. - return p.failAttempt(attemptID, sendErr) + return p.failAttempt(ctx, attemptID, sendErr) } // Otherwise fail both the payment and the attempt. @@ -893,7 +893,7 @@ func (p *paymentLifecycle) handleSwitchErr(ctx context.Context, log.Warnf("Failing attempt=%v for payment=%v as it's not "+ "found in the Switch", attempt.AttemptID, p.identifier) - return p.failAttempt(attemptID, sendErr) + return p.failAttempt(ctx, attemptID, sendErr) } if errors.Is(sendErr, htlcswitch.ErrUnreadableFailureMessage) { @@ -1025,11 +1025,9 @@ func (p *paymentLifecycle) handleFailureMessage(rt *route.Route, } // failAttempt calls control tower to fail the current payment attempt. -func (p *paymentLifecycle) failAttempt(attemptID uint64, +func (p *paymentLifecycle) failAttempt(ctx context.Context, attemptID uint64, sendError error) (*attemptResult, error) { - ctx := context.TODO() - log.Warnf("Attempt %v for payment %v failed: %v", attemptID, p.identifier, sendError) From 03d891d52dbced0707edcad18a7651575782d87c Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 11:44:35 +0200 Subject: [PATCH 64/78] routing: add context to reloadInflightAttempts --- routing/payment_lifecycle.go | 8 +++----- routing/payment_lifecycle_test.go | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index c6f6154c105..763cf43ab7f 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -209,7 +209,7 @@ func (p *paymentLifecycle) resumePayment(ctx context.Context) ([32]byte, // If we had any existing attempts outstanding, we'll start by spinning // up goroutines that'll collect their results and deliver them to the // lifecycle loop below. - payment, err := p.reloadInflightAttempts() + payment, err := p.reloadInflightAttempts(ctx) if err != nil { return [32]byte{}, nil, err } @@ -1138,10 +1138,8 @@ func (p *paymentLifecycle) patchLegacyPaymentHash( // reloadInflightAttempts is called when the payment lifecycle is resumed after // a restart. It reloads all inflight attempts from the control tower and // collects the results of the attempts that have been sent before. -func (p *paymentLifecycle) reloadInflightAttempts() (paymentsdb.DBMPPayment, - error) { - - ctx := context.TODO() +func (p *paymentLifecycle) reloadInflightAttempts( + ctx context.Context) (paymentsdb.DBMPPayment, error) { payment, err := p.router.cfg.Control.FetchPayment(ctx, p.identifier) if err != nil { diff --git a/routing/payment_lifecycle_test.go b/routing/payment_lifecycle_test.go index 61ae83a31fc..82e2f800d8f 100644 --- a/routing/payment_lifecycle_test.go +++ b/routing/payment_lifecycle_test.go @@ -1850,7 +1850,7 @@ func TestReloadInflightAttemptsLegacy(t *testing.T) { }) // Now call the method under test. - payment, err := p.reloadInflightAttempts() + payment, err := p.reloadInflightAttempts(t.Context()) require.NoError(t, err) require.Equal(t, m.payment, payment) From 4cc5428087468bd929ced631962f0498e22ca89a Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 11:46:35 +0200 Subject: [PATCH 65/78] routing: add context to reloadPayment method --- routing/payment_lifecycle.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 763cf43ab7f..9be86dc1bea 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -250,7 +250,7 @@ lifecycle: } // We update the payment state on every iteration. - currentPayment, ps, err := p.reloadPayment() + currentPayment, ps, err := p.reloadPayment(cleanupCtx) if err != nil { return exitWithErr(err) } @@ -1163,11 +1163,10 @@ func (p *paymentLifecycle) reloadInflightAttempts( } // reloadPayment returns the latest payment found in the db (control tower). -func (p *paymentLifecycle) reloadPayment() (paymentsdb.DBMPPayment, +func (p *paymentLifecycle) reloadPayment( + ctx context.Context) (paymentsdb.DBMPPayment, *paymentsdb.MPPaymentState, error) { - ctx := context.TODO() - // Read the db to get the latest state of the payment. payment, err := p.router.cfg.Control.FetchPayment(ctx, p.identifier) if err != nil { From d8d5d3f990c4a1f1e66daac323ddfeac3cf1b4cc Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 17 Nov 2025 14:51:23 +0100 Subject: [PATCH 66/78] multi: thread context through SendPayment --- routing/router.go | 6 +++--- routing/router_test.go | 42 ++++++++++++++++++++++++++++++------------ rpcserver.go | 2 +- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/routing/router.go b/routing/router.go index 71dcf7d8003..c0bc15c592d 100644 --- a/routing/router.go +++ b/routing/router.go @@ -896,8 +896,8 @@ func (l *LightningPayment) Identifier() [32]byte { // will be returned which describes the path the successful payment traversed // within the network to reach the destination. Additionally, the payment // preimage will also be returned. -func (r *ChannelRouter) SendPayment(payment *LightningPayment) ([32]byte, - *route.Route, error) { +func (r *ChannelRouter) SendPayment(ctx context.Context, + payment *LightningPayment) ([32]byte, *route.Route, error) { paySession, shardTracker, err := r.PreparePayment(payment) if err != nil { @@ -908,7 +908,7 @@ func (r *ChannelRouter) SendPayment(payment *LightningPayment) ([32]byte, spewPayment(payment)) return r.sendPayment( - context.Background(), payment.FeeLimit, payment.Identifier(), + ctx, payment.FeeLimit, payment.Identifier(), payment.PayAttemptTimeout, paySession, shardTracker, payment.FirstHopCustomRecords, ) diff --git a/routing/router_test.go b/routing/router_test.go index 9bc7bdbe11c..f1c6c3832c5 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -328,7 +328,9 @@ func TestSendPaymentRouteFailureFallback(t *testing.T) { // Send off the payment request to the router, route through pham nuwen // should've been selected as a fall back and succeeded correctly. - paymentPreImage, route, err := ctx.router.SendPayment(payment) + paymentPreImage, route, err := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -407,7 +409,9 @@ func TestSendPaymentRouteInfiniteLoopWithBadHopHint(t *testing.T) { // Send off the payment request to the router, should succeed // ignoring the bad channel id hint. - paymentPreImage, route, paymentErr := ctx.router.SendPayment(payment) + paymentPreImage, route, paymentErr := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, paymentErr, "unable to send payment: %v", payment.paymentHash) @@ -638,7 +642,9 @@ func TestSendPaymentErrorRepeatedFeeInsufficient(t *testing.T) { // Send off the payment request to the router, route through phamnuwen // should've been selected as a fall back and succeeded correctly. - paymentPreImage, route, err := ctx.router.SendPayment(payment) + paymentPreImage, route, err := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -745,7 +751,9 @@ func TestSendPaymentErrorFeeInsufficientPrivateEdge(t *testing.T) { // Send off the payment request to the router, route through son // goku and then across the private channel to elst. - paymentPreImage, route, err := ctx.router.SendPayment(payment) + paymentPreImage, route, err := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -871,7 +879,9 @@ func TestSendPaymentPrivateEdgeUpdateFeeExceedsLimit(t *testing.T) { // Send off the payment request to the router, route through son // goku and then across the private channel to elst. - paymentPreImage, route, err := ctx.router.SendPayment(payment) + paymentPreImage, route, err := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -994,7 +1004,9 @@ func TestSendPaymentErrorNonFinalTimeLockErrors(t *testing.T) { // Send off the payment request to the router, this payment should // succeed as we should actually go through Pham Nuwen in order to get // to Sophon, even though he has higher fees. - paymentPreImage, rt, err := ctx.router.SendPayment(payment) + paymentPreImage, rt, err := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -1020,7 +1032,9 @@ func TestSendPaymentErrorNonFinalTimeLockErrors(t *testing.T) { // w.r.t to the block height, and instead go through Pham Nuwen. We // flip a bit in the payment hash to allow resending this payment. payment.paymentHash[1] ^= 1 - paymentPreImage, rt, err = ctx.router.SendPayment(payment) + paymentPreImage, rt, err = ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -1089,7 +1103,7 @@ func TestSendPaymentErrorPathPruning(t *testing.T) { // When we try to dispatch that payment, we should receive an error as // both attempts should fail and cause both routes to be pruned. - _, _, err = ctx.router.SendPayment(payment) + _, _, err = ctx.router.SendPayment(t.Context(), payment) require.Error(t, err, "payment didn't return error") // The final error returned should also indicate that the peer wasn't @@ -1134,7 +1148,9 @@ func TestSendPaymentErrorPathPruning(t *testing.T) { // This shouldn't return an error, as we'll make a payment attempt via // the pham nuwen channel based on the assumption that there might be an // intermittent issue with the songoku <-> sophon channel. - paymentPreImage, rt, err := ctx.router.SendPayment(payment) + paymentPreImage, rt, err := ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -1174,7 +1190,9 @@ func TestSendPaymentErrorPathPruning(t *testing.T) { // We flip a bit in the payment hash to allow resending this payment. payment.paymentHash[1] ^= 1 - paymentPreImage, rt, err = ctx.router.SendPayment(payment) + paymentPreImage, rt, err = ctx.router.SendPayment( + t.Context(), payment, + ) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -1306,7 +1324,7 @@ func TestUnknownErrorSource(t *testing.T) { // the route a->b->c is tried first. An unreadable faiure is returned // which should pruning the channel a->b. We expect the payment to // succeed via a->d. - _, _, err = ctx.router.SendPayment(payment) + _, _, err = ctx.router.SendPayment(t.Context(), payment) require.NoErrorf(t, err, "unable to send payment: %v", payment.paymentHash) @@ -1331,7 +1349,7 @@ func TestUnknownErrorSource(t *testing.T) { // Send off the payment request to the router. We expect the payment to // fail because both routes have been pruned. payment.paymentHash[1] ^= 1 - _, _, err = ctx.router.SendPayment(payment) + _, _, err = ctx.router.SendPayment(t.Context(), payment) if err == nil { t.Fatalf("expected payment to fail") } diff --git a/rpcserver.go b/rpcserver.go index 3909fb1a7f8..c40d973c64c 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6022,7 +6022,7 @@ func (r *rpcServer) dispatchPaymentIntent(ctx context.Context, } preImage, route, routerErr = r.server.chanRouter.SendPayment( - payment, + ctx, payment, ) } else { var attempt *paymentsdb.HTLCAttempt From 9da39e565528a75f4771b497b6910ba99b7ac70e Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 21 Oct 2025 11:51:02 +0200 Subject: [PATCH 67/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index 8a88b28f9bc..0683f38096e 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -213,6 +213,8 @@ for SQL backend](https://github.com/lightningnetwork/lnd/pull/10292) * [Thread context through payment db functions Part 1](https://github.com/lightningnetwork/lnd/pull/10307) + * [Thread context through payment + db functions Part 2](https://github.com/lightningnetwork/lnd/pull/10308) ## Code Health From 3ae0ccc6f5c1aa64a0d5946f31ca8aba8f603d53 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 15 Nov 2025 08:43:57 +0100 Subject: [PATCH 68/78] paymentsdb: make delete payments test db agnostic We make the TestDeleteNonInFlight and separate all the logic out for the duplicate payment test case. The deletion of duplicate payments is now tested in isolation only for the kv backend. --- payments/db/kv_store_test.go | 265 +++++++++-------------------------- payments/db/payment_test.go | 154 ++++++++++++++++++++ 2 files changed, 220 insertions(+), 199 deletions(-) diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index de3fc4ad24f..76e218e52ec 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -23,225 +23,92 @@ import ( "github.com/stretchr/testify/require" ) -// TestKVStoreDeleteNonInFlight checks that calling DeletePayments only -// deletes payments from the database that are not in-flight. -// -// TODO(ziggie): Make this test db agnostic. -func TestKVStoreDeleteNonInFlight(t *testing.T) { +// TestKVStoreDeleteDuplicatePayments tests that when a payment with duplicate +// payments is deleted, both the parent payment and its duplicates are properly +// removed from the payment index. This is specific to the KV store's legacy +// duplicate payment handling. +func TestKVStoreDeleteDuplicatePayments(t *testing.T) { t.Parallel() ctx := t.Context() paymentDB := NewKVTestDB(t) - // Create a sequence number for duplicate payments that will not collide - // with the sequence numbers for the payments we create. These values - // start at 1, so 9999 is a safe bet for this test. - var duplicateSeqNr = 9999 - - payments := []struct { - failed bool - success bool - hasDuplicate bool - }{ - { - failed: true, - success: false, - hasDuplicate: false, - }, - { - failed: false, - success: true, - hasDuplicate: false, - }, - { - failed: false, - success: false, - hasDuplicate: false, - }, - { - failed: false, - success: true, - hasDuplicate: true, - }, - } - - var numSuccess, numInflight int - - for _, p := range payments { - preimg, err := genPreimage(t) - require.NoError(t, err) - - rhash := sha256.Sum256(preimg[:]) - info := genPaymentCreationInfo(t, rhash) - attempt, err := genAttemptWithHash( - t, 0, genSessionKey(t), rhash, - ) - require.NoError(t, err) - - // Sends base htlc message which initiate StatusInFlight. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) - if err != nil { - t.Fatalf("unable to send htlc message: %v", err) - } - _, err = paymentDB.RegisterAttempt( - ctx, info.PaymentIdentifier, attempt, - ) - if err != nil { - t.Fatalf("unable to send htlc message: %v", err) - } - - htlc := &htlcStatus{ - HTLCAttemptInfo: attempt, - } - - switch { - case p.failed: - // Fail the payment attempt. - htlcFailure := HTLCFailUnreadable - _, err := paymentDB.FailAttempt( - ctx, info.PaymentIdentifier, attempt.AttemptID, - &HTLCFailInfo{ - Reason: htlcFailure, - }, - ) - if err != nil { - t.Fatalf("unable to fail htlc: %v", err) - } + // Create a successful payment. + preimg, err := genPreimage(t) + require.NoError(t, err) - // Fail the payment, which should moved it to Failed. - failReason := FailureReasonNoRoute - _, err = paymentDB.Fail( - ctx, info.PaymentIdentifier, failReason, - ) - if err != nil { - t.Fatalf("unable to fail payment hash: %v", err) - } + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + attempt, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + require.NoError(t, err) - // Verify the status is indeed Failed. - assertDBPaymentstatus( - t, paymentDB, info.PaymentIdentifier, - StatusFailed, - ) + // Init and settle the payment. + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err, "unable to init payment") - htlc.failure = &htlcFailure - assertPaymentInfo( - t, paymentDB, info.PaymentIdentifier, info, - &failReason, htlc, - ) + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt, + ) + require.NoError(t, err, "unable to register attempt") - case p.success: - // Verifies that status was changed to StatusSucceeded. - _, err := paymentDB.SettleAttempt( - ctx, info.PaymentIdentifier, attempt.AttemptID, - &HTLCSettleInfo{ - Preimage: preimg, - }, - ) - if err != nil { - t.Fatalf("error shouldn't have been received,"+ - " got: %v", err) - } + _, err = paymentDB.SettleAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, + &HTLCSettleInfo{ + Preimage: preimg, + }, + ) + require.NoError(t, err, "unable to settle attempt") - assertDBPaymentstatus( - t, paymentDB, info.PaymentIdentifier, - StatusSucceeded, - ) + assertDBPaymentstatus( + t, paymentDB, info.PaymentIdentifier, StatusSucceeded, + ) - htlc.settle = &preimg - assertPaymentInfo( - t, paymentDB, info.PaymentIdentifier, info, nil, - htlc, - ) + // Fetch the payment to get its sequence number. + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) - numSuccess++ + // Add two duplicate payments. Use high sequence numbers that won't + // collide with the original payment. + duplicateSeqNr1 := payment.SequenceNum + 1000 + duplicateSeqNr2 := payment.SequenceNum + 1001 - default: - assertDBPaymentstatus( - t, paymentDB, info.PaymentIdentifier, - StatusInFlight, - ) - assertPaymentInfo( - t, paymentDB, info.PaymentIdentifier, info, nil, - htlc, - ) + appendDuplicatePayment( + t, paymentDB.db, info.PaymentIdentifier, duplicateSeqNr1, + preimg, + ) + appendDuplicatePayment( + t, paymentDB.db, info.PaymentIdentifier, duplicateSeqNr2, + preimg, + ) - numInflight++ - } + // Verify we now have 3 index entries: original + 2 duplicates. + var indexCount int + err = kvdb.View(paymentDB.db, func(tx walletdb.ReadTx) error { + index := tx.ReadBucket(paymentsIndexBucket) - // If the payment is intended to have a duplicate payment, we - // add one. - if p.hasDuplicate { - appendDuplicatePayment( - t, paymentDB.db, info.PaymentIdentifier, - uint64(duplicateSeqNr), preimg, - ) - duplicateSeqNr++ - numSuccess++ - } - } + return index.ForEach(func(k, v []byte) error { + indexCount++ + return nil + }) + }, func() { indexCount = 0 }) + require.NoError(t, err) + require.Equal(t, 3, indexCount, "expected 3 index entries "+ + "(parent + 2 duplicates)") - // Delete all failed payments. - numPayments, err := paymentDB.DeletePayments(ctx, true, false) + // Delete all successful payments. + numPayments, err := paymentDB.DeletePayments(ctx, false, false) require.NoError(t, err) - require.EqualValues(t, 1, numPayments) + require.EqualValues(t, 1, numPayments, "should delete 1 payment") - // This should leave the succeeded and in-flight payments. + // Verify all payments are deleted. dbPayments, err := paymentDB.FetchPayments() - if err != nil { - t.Fatal(err) - } - - if len(dbPayments) != numSuccess+numInflight { - t.Fatalf("expected %d payments, got %d", - numSuccess+numInflight, len(dbPayments)) - } - - var s, i int - for _, p := range dbPayments { - t.Log("fetch payment has status", p.Status) - switch p.Status { - case StatusSucceeded: - s++ - case StatusInFlight: - i++ - } - } - - if s != numSuccess { - t.Fatalf("expected %d succeeded payments , got %d", - numSuccess, s) - } - if i != numInflight { - t.Fatalf("expected %d in-flight payments, got %d", - numInflight, i) - } - - // Now delete all payments except in-flight. - numPayments, err = paymentDB.DeletePayments(ctx, false, false) require.NoError(t, err) - require.EqualValues(t, 2, numPayments) + require.Empty(t, dbPayments, "all payments should be deleted") - // This should leave the in-flight payment. - dbPayments, err = paymentDB.FetchPayments() - if err != nil { - t.Fatal(err) - } - - if len(dbPayments) != numInflight { - t.Fatalf("expected %d payments, got %d", numInflight, - len(dbPayments)) - } - - for _, p := range dbPayments { - if p.Status != StatusInFlight { - t.Fatalf("expected in-fligth status, got %v", p.Status) - } - } - - // Finally, check that we only have a single index left in the payment - // index bucket. - var indexCount int + // Verify the payment index is now empty - all 3 entries (parent + + // duplicates) should be removed. + indexCount = 0 err = kvdb.View(paymentDB.db, func(tx walletdb.ReadTx) error { index := tx.ReadBucket(paymentsIndexBucket) @@ -251,8 +118,8 @@ func TestKVStoreDeleteNonInFlight(t *testing.T) { }) }, func() { indexCount = 0 }) require.NoError(t, err) - - require.Equal(t, 1, indexCount) + require.Equal(t, 0, indexCount, "payment index should be empty "+ + "after deleting payment with duplicates") } func makeFakeInfo(t *testing.T) (*PaymentCreationInfo, diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 25aafbb5464..581ac2c1907 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -1745,6 +1745,160 @@ func TestDeletePayments(t *testing.T) { assertDBPayments(t, paymentDB, payments[2:]) } +// TestDeleteNonInFlight checks that calling DeletePayments only deletes +// payments from the database that are not in-flight. +func TestDeleteNonInFlight(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + // Create payments with different statuses: failed, success, inflight, + // and another success. + payments := []struct { + failed bool + success bool + }{ + // Payment 0: failed. + {failed: true, success: false}, + // Payment 1: success. + {failed: false, success: true}, + // Payment 2: inflight. + {failed: false, success: false}, + // Payment 3: success. + {failed: false, success: true}, + } + + var numSuccess, numInflight int + + for _, p := range payments { + preimg, err := genPreimage(t) + require.NoError(t, err) + + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + attempt, err := genAttemptWithHash( + t, 0, genSessionKey(t), rhash, + ) + require.NoError(t, err) + + // Init payment which initiates StatusInFlight. + err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err, "unable to init payment") + + _, err = paymentDB.RegisterAttempt( + ctx, info.PaymentIdentifier, attempt, + ) + require.NoError(t, err, "unable to register attempt") + + switch { + case p.failed: + // Fail the payment attempt. + htlcFailure := HTLCFailUnreadable + _, err := paymentDB.FailAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, + &HTLCFailInfo{ + Reason: htlcFailure, + }, + ) + require.NoError(t, err, "unable to fail htlc") + + // Fail the payment, which should move it to Failed. + failReason := FailureReasonNoRoute + _, err = paymentDB.Fail( + ctx, info.PaymentIdentifier, failReason, + ) + require.NoError(t, err, "unable to fail payment") + + // Verify the status is indeed Failed. + assertDBPaymentstatus( + t, paymentDB, info.PaymentIdentifier, + StatusFailed, + ) + + case p.success: + // Settle the attempt. + _, err := paymentDB.SettleAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, + &HTLCSettleInfo{ + Preimage: preimg, + }, + ) + require.NoError(t, err, "unable to settle attempt") + + assertDBPaymentstatus( + t, paymentDB, info.PaymentIdentifier, + StatusSucceeded, + ) + + numSuccess++ + + default: + // Leave as inflight. + assertDBPaymentstatus( + t, paymentDB, info.PaymentIdentifier, + StatusInFlight, + ) + + numInflight++ + } + } + + // Delete all failed payments. + numPayments, err := paymentDB.DeletePayments(ctx, true, false) + require.NoError(t, err) + require.EqualValues(t, 1, numPayments) + + // This should leave the succeeded and in-flight payments. + resp, err := paymentDB.QueryPayments(ctx, Query{ + IndexOffset: 0, + MaxPayments: math.MaxUint64, + IncludeIncomplete: true, + }) + require.NoError(t, err) + + require.Equal(t, numSuccess+numInflight, len(resp.Payments), + "expected %d payments, got %d", numSuccess+numInflight, + len(resp.Payments)) + + var s, i int + for _, p := range resp.Payments { + switch p.Status { + case StatusSucceeded: + s++ + case StatusInFlight: + i++ + } + } + + require.Equal(t, numSuccess, s, + "expected %d succeeded payments, got %d", numSuccess, s) + require.Equal(t, numInflight, i, + "expected %d in-flight payments, got %d", numInflight, i) + + // Now delete all payments except in-flight. + numPayments, err = paymentDB.DeletePayments(ctx, false, false) + require.NoError(t, err) + require.EqualValues(t, 2, numPayments) + + // This should leave the in-flight payment. + resp, err = paymentDB.QueryPayments(ctx, Query{ + IndexOffset: 0, + MaxPayments: math.MaxUint64, + IncludeIncomplete: true, + }) + require.NoError(t, err) + + require.Equal(t, numInflight, len(resp.Payments), + "expected %d payments, got %d", numInflight, len(resp.Payments)) + + for _, p := range resp.Payments { + require.Equal(t, StatusInFlight, p.Status, + "expected in-flight status, got %v", p.Status) + } +} + // TestSwitchDoubleSend checks the ability of payment control to // prevent double sending of htlc message, when message is in StatusInFlight. func TestSwitchDoubleSend(t *testing.T) { From 253e4fd832c564619c96644d7df8022298a9b1a5 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 16 Nov 2025 15:48:52 +0100 Subject: [PATCH 69/78] routing: add TODO to also delete payments without HTLCs --- routing/router.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routing/router.go b/routing/router.go index c0bc15c592d..c17aa417630 100644 --- a/routing/router.go +++ b/routing/router.go @@ -1439,6 +1439,11 @@ func (r *ChannelRouter) resumePayments() error { log.Debugf("Scanning finished, found %d inflight payments", len(payments)) + // TODO(ziggie): Also check for payments which have no HTLCs at all + // this can happen because we register an attempt after initializing the + // payment, so there is a small chance that we init a payment but never + // register an attempt for it. + // Before we restart existing payments and start accepting more // payments to be made, we clean the network result store of the // Switch. We do this here at startup to ensure no more payments can be From 995ad7e1b53bd29df5a19cfed5665b0a3830a3ba Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 18 Nov 2025 01:04:31 +0100 Subject: [PATCH 70/78] multi: move failed attempt cfg option to the router subsytem Previously we had db and application logic mixed on the db level. We now move the config option KeepFailedPaymentAttempts to the ChannelRouter level and move it out of the db level. --- config_builder.go | 6 -- payments/db/kv_store.go | 22 ++--- payments/db/options.go | 14 +-- payments/db/payment_test.go | 88 ++++++------------ payments/db/sql_store.go | 22 +---- routing/control_tower_test.go | 45 ++------- routing/payment_lifecycle.go | 19 ++-- routing/payment_lifecycle_test.go | 150 ++++++++++++++++++++++++++++++ routing/router.go | 4 + server.go | 29 +++--- 10 files changed, 224 insertions(+), 175 deletions(-) diff --git a/config_builder.go b/config_builder.go index 07196b21c4f..74f8b3afb74 100644 --- a/config_builder.go +++ b/config_builder.go @@ -1236,9 +1236,6 @@ func (d *DefaultDatabaseBuilder) BuildDatabase( // will build a SQL payments backend. sqlPaymentsDB, err := d.getPaymentsStore( baseDB, dbs.ChanStateDB.Backend, - paymentsdb.WithKeepFailedPaymentAttempts( - cfg.KeepFailedPaymentAttempts, - ), ) if err != nil { err = fmt.Errorf("unable to get payments store: %w", @@ -1280,9 +1277,6 @@ func (d *DefaultDatabaseBuilder) BuildDatabase( // Create the payments DB. kvPaymentsDB, err := paymentsdb.NewKVStore( dbs.ChanStateDB, - paymentsdb.WithKeepFailedPaymentAttempts( - cfg.KeepFailedPaymentAttempts, - ), ) if err != nil { cleanUp() diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 0ce0601e498..6d21048d7e0 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -127,10 +127,6 @@ type KVStore struct { // db is the underlying database implementation. db kvdb.Backend - - // keepFailedPaymentAttempts is a flag that indicates whether we should - // keep failed payment attempts in the database. - keepFailedPaymentAttempts bool } // A compile-time constraint to ensure KVStore implements DB. @@ -152,8 +148,7 @@ func NewKVStore(db kvdb.Backend, } return &KVStore{ - db: db, - keepFailedPaymentAttempts: opts.KeepFailedPaymentAttempts, + db: db, }, nil } @@ -288,19 +283,14 @@ func (p *KVStore) InitPayment(_ context.Context, paymentHash lntypes.Hash, return updateErr } -// DeleteFailedAttempts deletes all failed htlcs for a payment if configured -// by the KVStore db. +// DeleteFailedAttempts deletes all failed htlcs for a payment. func (p *KVStore) DeleteFailedAttempts(ctx context.Context, hash lntypes.Hash) error { - // TODO(ziggie): Refactor to not mix application logic with database - // logic. This decision should be made in the application layer. - if !p.keepFailedPaymentAttempts { - const failedHtlcsOnly = true - err := p.DeletePayment(ctx, hash, failedHtlcsOnly) - if err != nil { - return err - } + const failedHtlcsOnly = true + err := p.DeletePayment(ctx, hash, failedHtlcsOnly) + if err != nil { + return err } return nil diff --git a/payments/db/options.go b/payments/db/options.go index 9e98aafa3ac..efceb2f9b71 100644 --- a/payments/db/options.go +++ b/payments/db/options.go @@ -4,17 +4,12 @@ package paymentsdb type StoreOptions struct { // NoMigration allows to open the database in readonly mode NoMigration bool - - // KeepFailedPaymentAttempts is a flag that determines whether to keep - // failed payment attempts for a settled payment in the db. - KeepFailedPaymentAttempts bool } // DefaultOptions returns a StoreOptions populated with default values. func DefaultOptions() *StoreOptions { return &StoreOptions{ - KeepFailedPaymentAttempts: false, - NoMigration: false, + NoMigration: false, } } @@ -22,13 +17,6 @@ func DefaultOptions() *StoreOptions { // StoreOptions. type OptionModifier func(*StoreOptions) -// WithKeepFailedPaymentAttempts sets the KeepFailedPaymentAttempts to n. -func WithKeepFailedPaymentAttempts(n bool) OptionModifier { - return func(o *StoreOptions) { - o.KeepFailedPaymentAttempts = n - } -} - // WithNoMigration allows the database to be opened in read only mode by // disabling migrations. func WithNoMigration(b bool) OptionModifier { diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 581ac2c1907..668ac4c1d0a 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -460,20 +460,7 @@ func genInfo(t *testing.T) (*PaymentCreationInfo, lntypes.Preimage, error) { func TestDeleteFailedAttempts(t *testing.T) { t.Parallel() - t.Run("keep failed payment attempts", func(t *testing.T) { - testDeleteFailedAttempts(t, true) - }) - t.Run("remove failed payment attempts", func(t *testing.T) { - testDeleteFailedAttempts(t, false) - }) -} - -// testDeleteFailedAttempts tests the DeleteFailedAttempts method with the -// given keepFailedPaymentAttempts flag as argument. -func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { - paymentDB, _ := NewTestDB( - t, WithKeepFailedPaymentAttempts(keepFailedPaymentAttempts), - ) + paymentDB, _ := NewTestDB(t) // Register three payments: // All payments will have one failed HTLC attempt and one HTLC attempt @@ -507,29 +494,16 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { t.Context(), payments[0].id, )) - // Expect all HTLCs to be deleted if the config is set to delete them. - if !keepFailedPaymentAttempts { - payments[0].htlcs = 0 - } + // Expect all HTLCs to be deleted. + payments[0].htlcs = 0 assertDBPayments(t, paymentDB, payments) // Calling DeleteFailedAttempts on an in-flight payment should return // an error. - // - // NOTE: In case the option keepFailedPaymentAttempts is set no delete - // operation are performed in general therefore we do NOT expect an - // error in this case. - if keepFailedPaymentAttempts { - err := paymentDB.DeleteFailedAttempts( - t.Context(), payments[1].id, - ) - require.NoError(t, err) - } else { - err := paymentDB.DeleteFailedAttempts( - t.Context(), payments[1].id, - ) - require.Error(t, err) - } + err := paymentDB.DeleteFailedAttempts( + t.Context(), payments[1].id, + ) + require.Error(t, err) // Since DeleteFailedAttempts returned an error, we should expect the // payment to be unchanged. @@ -540,34 +514,16 @@ func testDeleteFailedAttempts(t *testing.T, keepFailedPaymentAttempts bool) { t.Context(), payments[2].id, )) - // Expect all HTLCs except for the settled one to be deleted if the - // config is set to delete them. - if !keepFailedPaymentAttempts { - payments[2].htlcs = 1 - } + // Expect all HTLCs except for the settled one to be deleted. + payments[2].htlcs = 1 assertDBPayments(t, paymentDB, payments) - // NOTE: In case the option keepFailedPaymentAttempts is set no delete - // operation are performed in general therefore we do NOT expect an - // error in this case. - if keepFailedPaymentAttempts { - // DeleteFailedAttempts is ignored, even for non-existent - // payments, if the control tower is configured to keep failed - // HTLCs. - require.NoError( - t, paymentDB.DeleteFailedAttempts( - t.Context(), lntypes.ZeroHash, - ), - ) - } else { - // Attempting to cleanup a non-existent payment returns an - // error. - require.Error( - t, paymentDB.DeleteFailedAttempts( - t.Context(), lntypes.ZeroHash, - ), - ) - } + // Attempting to cleanup a non-existent payment returns an error. + require.Error( + t, paymentDB.DeleteFailedAttempts( + t.Context(), lntypes.ZeroHash, + ), + ) } // TestMPPRecordValidation tests MPP record validation. @@ -1754,6 +1710,11 @@ func TestDeleteNonInFlight(t *testing.T) { paymentDB, _ := NewTestDB(t) + var ( + numSuccess, numInflight int + attemptID uint64 = 0 + ) + // Create payments with different statuses: failed, success, inflight, // and another success. payments := []struct { @@ -1770,8 +1731,6 @@ func TestDeleteNonInFlight(t *testing.T) { {failed: false, success: true}, } - var numSuccess, numInflight int - for _, p := range payments { preimg, err := genPreimage(t) require.NoError(t, err) @@ -1779,10 +1738,15 @@ func TestDeleteNonInFlight(t *testing.T) { rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) attempt, err := genAttemptWithHash( - t, 0, genSessionKey(t), rhash, + t, attemptID, genSessionKey(t), rhash, ) require.NoError(t, err) + // After generating the attempt, increment the attempt ID to + // have unique attempt IDs for each attempt otherwise the unique + // constraint on the attempt ID will be violated. + attemptID++ + // Init payment which initiates StatusInFlight. err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to init payment") diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index d23e80895f9..1c6e3042a93 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -99,10 +99,6 @@ type BatchedSQLQueries interface { type SQLStore struct { cfg *SQLStoreConfig db BatchedSQLQueries - - // keepFailedPaymentAttempts is a flag that indicates whether we should - // keep failed payment attempts in the database. - keepFailedPaymentAttempts bool } // A compile-time constraint to ensure SQLStore implements DB. @@ -130,9 +126,8 @@ func NewSQLStore(cfg *SQLStoreConfig, db BatchedSQLQueries, } return &SQLStore{ - cfg: cfg, - db: db, - keepFailedPaymentAttempts: opts.KeepFailedPaymentAttempts, + cfg: cfg, + db: db, }, nil } @@ -1094,10 +1089,6 @@ func (s *SQLStore) FetchInFlightPayments(ctx context.Context) ([]*MPPayment, // - StatusSucceeded: Can delete failed attempts (payment completed) // - StatusFailed: Can delete failed attempts (payment permanently failed) // -// If the keepFailedPaymentAttempts configuration flag is enabled, this method -// returns immediately without deleting anything, allowing failed attempts to -// be retained for debugging or auditing purposes. -// // This method is idempotent - calling it multiple times on the same payment // has no adverse effects. // @@ -1109,15 +1100,6 @@ func (s *SQLStore) FetchInFlightPayments(ctx context.Context) ([]*MPPayment, func (s *SQLStore) DeleteFailedAttempts(ctx context.Context, paymentHash lntypes.Hash) error { - // In case we are configured to keep failed payment attempts, we exit - // early. - // - // TODO(ziggie): Refactor to not mix application logic with database - // logic. This decision should be made in the application layer. - if s.keepFailedPaymentAttempts { - return nil - } - err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { dbPayment, err := fetchPaymentByHash(ctx, db, paymentHash) if err != nil { diff --git a/routing/control_tower_test.go b/routing/control_tower_test.go index c9e8f485733..697770ff506 100644 --- a/routing/control_tower_test.go +++ b/routing/control_tower_test.go @@ -50,10 +50,7 @@ func TestControlTowerSubscribeUnknown(t *testing.T) { db := initDB(t) - paymentDB, err := paymentsdb.NewKVStore( - db, - paymentsdb.WithKeepFailedPaymentAttempts(true), - ) + paymentDB, err := paymentsdb.NewKVStore(db) require.NoError(t, err) pControl := NewControlTower(paymentDB) @@ -182,17 +179,11 @@ func TestControlTowerSubscribeSuccess(t *testing.T) { func TestKVStoreSubscribeFail(t *testing.T) { t.Parallel() - t.Run("register attempt, keep failed payments", func(t *testing.T) { - testKVStoreSubscribeFail(t, true, true) - }) - t.Run("register attempt, delete failed payments", func(t *testing.T) { - testKVStoreSubscribeFail(t, true, false) - }) - t.Run("no register attempt, keep failed payments", func(t *testing.T) { - testKVStoreSubscribeFail(t, false, true) + t.Run("register attempt", func(t *testing.T) { + testKVStoreSubscribeFail(t, true) }) - t.Run("no register attempt, delete failed payments", func(t *testing.T) { - testKVStoreSubscribeFail(t, false, false) + t.Run("no register attempt", func(t *testing.T) { + testKVStoreSubscribeFail(t, false) }) } @@ -203,10 +194,7 @@ func TestKVStoreSubscribeAllSuccess(t *testing.T) { db := initDB(t) - paymentDB, err := paymentsdb.NewKVStore( - db, - paymentsdb.WithKeepFailedPaymentAttempts(true), - ) + paymentDB, err := paymentsdb.NewKVStore(db) require.NoError(t, err) pControl := NewControlTower(paymentDB) @@ -334,10 +322,7 @@ func TestKVStoreSubscribeAllImmediate(t *testing.T) { db := initDB(t) - paymentDB, err := paymentsdb.NewKVStore( - db, - paymentsdb.WithKeepFailedPaymentAttempts(true), - ) + paymentDB, err := paymentsdb.NewKVStore(db) require.NoError(t, err) pControl := NewControlTower(paymentDB) @@ -385,10 +370,7 @@ func TestKVStoreUnsubscribeSuccess(t *testing.T) { db := initDB(t) - paymentDB, err := paymentsdb.NewKVStore( - db, - paymentsdb.WithKeepFailedPaymentAttempts(true), - ) + paymentDB, err := paymentsdb.NewKVStore(db) require.NoError(t, err) pControl := NewControlTower(paymentDB) @@ -458,17 +440,10 @@ func TestKVStoreUnsubscribeSuccess(t *testing.T) { require.Len(t, subscription2.Updates(), 0) } -func testKVStoreSubscribeFail(t *testing.T, registerAttempt, - keepFailedPaymentAttempts bool) { - +func testKVStoreSubscribeFail(t *testing.T, registerAttempt bool) { db := initDB(t) - paymentDB, err := paymentsdb.NewKVStore( - db, - paymentsdb.WithKeepFailedPaymentAttempts( - keepFailedPaymentAttempts, - ), - ) + paymentDB, err := paymentsdb.NewKVStore(db) require.NoError(t, err) pControl := NewControlTower(paymentDB) diff --git a/routing/payment_lifecycle.go b/routing/payment_lifecycle.go index 9be86dc1bea..488df5b796e 100644 --- a/routing/payment_lifecycle.go +++ b/routing/payment_lifecycle.go @@ -338,15 +338,16 @@ lifecycle: // terminal condition. We either return the settled preimage or the // payment's failure reason. // - // Optionally delete the failed attempts from the database. Depends on - // the database options deleting attempts is not allowed so this will - // just be a no-op. - err = p.router.cfg.Control.DeleteFailedAttempts( - cleanupCtx, p.identifier, - ) - if err != nil { - log.Errorf("Error deleting failed htlc attempts for payment "+ - "%v: %v", p.identifier, err) + // Optionally delete the failed attempts from the database. If we are + // configured to keep failed payment attempts, we skip deletion. + if !p.router.cfg.KeepFailedPaymentAttempts { + err = p.router.cfg.Control.DeleteFailedAttempts( + cleanupCtx, p.identifier, + ) + if err != nil { + log.Errorf("Error deleting failed htlc attempts "+ + "for payment %v: %v", p.identifier, err) + } } htlc, failure := payment.TerminalInfo() diff --git a/routing/payment_lifecycle_test.go b/routing/payment_lifecycle_test.go index 82e2f800d8f..564942d327e 100644 --- a/routing/payment_lifecycle_test.go +++ b/routing/payment_lifecycle_test.go @@ -1280,6 +1280,156 @@ func TestResumePaymentSuccess(t *testing.T) { require.Equal(t, 1, m.collectResultsCount) } +// TestKeepFailedPaymentAttempts tests that DeleteFailedAttempts is +// called or skipped based on the KeepFailedPaymentAttempts +// configuration of the router. +func TestKeepFailedPaymentAttempts(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + keepFailedPaymentAttempts bool + expectDeleteCalled bool + }{ + { + name: "keep failed attempts - " + + "delete not called", + keepFailedPaymentAttempts: true, + expectDeleteCalled: false, + }, + { + name: "delete failed attempts - " + + "delete called", + keepFailedPaymentAttempts: false, + expectDeleteCalled: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create a test paymentLifecycle with the initial two + // calls mocked. + p, m := setupTestPaymentLifecycle(t) + + // Set the KeepFailedPaymentAttempts configuration. + p.router.cfg.KeepFailedPaymentAttempts = + tc.keepFailedPaymentAttempts + + // Create a dummy route that will be returned by + // `RequestRoute`. + paymentAmt := lnwire.MilliSatoshi(10000) + rt := createDummyRoute(t, paymentAmt) + + // We now enter the payment lifecycle loop. + // + // 1.1. calls `FetchPayment` and return the payment. + m.control.On("FetchPayment", p.identifier). + Return(m.payment, nil).Once() + + // 1.2. calls `GetState` and return the state. + ps := &paymentsdb.MPPaymentState{ + RemainingAmt: paymentAmt, + } + m.payment.On("GetState").Return(ps).Once() + + // NOTE: GetStatus is only used to populate the logs + // which is not critical so we loosen the checks on how + // many times it's been called. + m.payment.On("GetStatus"). + Return(paymentsdb.StatusInFlight) + + // 1.3. decideNextStep now returns stepProceed. + m.payment.On("AllowMoreAttempts"). + Return(true, nil).Once() + + // 1.4. mock requestRoute to return an route. + m.paySession.On("RequestRoute", + paymentAmt, p.feeLimit, + uint32(ps.NumAttemptsInFlight), + uint32(p.currentHeight), mock.Anything, + ).Return(rt, nil).Once() + + // 1.5. mock `registerAttempt` to return an attempt. + // + // Mock NextPaymentID to always return the attemptID. + attemptID := uint64(1) + p.router.cfg.NextPaymentID = func() (uint64, error) { + return attemptID, nil + } + + // Mock shardTracker to return the mock shard. + m.shardTracker.On("NewShard", + attemptID, true, + ).Return(m.shard, nil).Once() + + // Mock the methods on the shard. + m.shard.On("MPP").Return(&record.MPP{}).Twice(). + On("AMP").Return(nil).Once(). + On("Hash").Return(p.identifier).Once() + + // Mock the time and expect it to be called. + m.clock.On("Now").Return(time.Now()) + + // We now register attempt and return no error. + m.control.On("RegisterAttempt", + p.identifier, mock.Anything, + ).Return(nil).Once() + + // 1.6. mock `sendAttempt` to succeed, which brings us + // into the next iteration of the lifecycle. + m.payer.On("SendHTLC", + mock.Anything, attemptID, mock.Anything, + ).Return(nil).Once() + + // We now enter the second iteration of the lifecycle + // loop. + // + // 2.1. calls `FetchPayment` and return the payment. + m.control.On("FetchPayment", p.identifier). + Return(m.payment, nil).Once() + + // 2.2. calls `GetState` and return the state. + m.payment.On("GetState").Return(ps). + Run(func(args mock.Arguments) { + ps.RemainingAmt = 0 + }).Once() + + // 2.3. decideNextStep now returns stepExit and exits + // the loop. + m.payment.On("AllowMoreAttempts"). + Return(false, nil).Once(). + On("NeedWaitAttempts").Return(false, nil).Once() + + // Conditionally expect DeleteFailedAttempts to be + // called based on the configuration. + if tc.expectDeleteCalled { + m.control.On("DeleteFailedAttempts", + p.identifier).Return(nil).Once() + } + // If expectDeleteCalled is false, we don't set up the + // expectation, which means the mock will fail if it's + // called. + + // Finally, mock the `TerminalInfo` to return the + // settled attempt. Create a SettleAttempt. + testPreimage := lntypes.Preimage{1, 2, 3} + settledAttempt := makeSettledAttempt( + t, int(paymentAmt), testPreimage, + ) + m.payment.On("TerminalInfo"). + Return(settledAttempt, nil).Once() + + // Send the payment and assert the preimage is matched. + sendPaymentAndAssertSucceeded(t, p, testPreimage) + + // Expected collectResultAsync to called. + require.Equal(t, 1, m.collectResultsCount) + }) + } +} + // TestResumePaymentSuccessWithTwoAttempts checks a successful payment flow // with two HTLC attempts. // diff --git a/routing/router.go b/routing/router.go index c17aa417630..37aeef22361 100644 --- a/routing/router.go +++ b/routing/router.go @@ -295,6 +295,10 @@ type Config struct { // TrafficShaper is an optional traffic shaper that can be used to // control the outgoing channel of a payment. TrafficShaper fn.Option[htlcswitch.AuxTrafficShaper] + + // KeepFailedPaymentAttempts indicates whether to keep failed payment + // attempts in the database. + KeepFailedPaymentAttempts bool } // EdgeLocator is a struct used to identify a specific edge. diff --git a/server.go b/server.go index 91b6624631c..eaa5a528cf8 100644 --- a/server.go +++ b/server.go @@ -1023,20 +1023,21 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, } s.chanRouter, err = routing.New(routing.Config{ - SelfNode: nodePubKey, - RoutingGraph: dbs.GraphDB, - Chain: cc.ChainIO, - Payer: s.htlcSwitch, - Control: s.controlTower, - MissionControl: s.defaultMC, - SessionSource: paymentSessionSource, - GetLink: s.htlcSwitch.GetLinkByShortID, - NextPaymentID: sequencer.NextID, - PathFindingConfig: pathFindingConfig, - Clock: clock.NewDefaultClock(), - ApplyChannelUpdate: s.graphBuilder.ApplyChannelUpdate, - ClosedSCIDs: s.fetchClosedChannelSCIDs(), - TrafficShaper: implCfg.TrafficShaper, + SelfNode: nodePubKey, + RoutingGraph: dbs.GraphDB, + Chain: cc.ChainIO, + Payer: s.htlcSwitch, + Control: s.controlTower, + MissionControl: s.defaultMC, + SessionSource: paymentSessionSource, + GetLink: s.htlcSwitch.GetLinkByShortID, + NextPaymentID: sequencer.NextID, + PathFindingConfig: pathFindingConfig, + Clock: clock.NewDefaultClock(), + ApplyChannelUpdate: s.graphBuilder.ApplyChannelUpdate, + ClosedSCIDs: s.fetchClosedChannelSCIDs(), + TrafficShaper: implCfg.TrafficShaper, + KeepFailedPaymentAttempts: cfg.KeepFailedPaymentAttempts, }) if err != nil { return nil, fmt.Errorf("can't create router: %w", err) From a861ac8d46aefa6bfc712da39e8221f858abaf4d Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 21 Nov 2025 01:48:13 +0100 Subject: [PATCH 71/78] paymentsdb: refactor test helpers --- payments/db/kv_store_test.go | 30 +++----- payments/db/payment_test.go | 138 +++++++++++++---------------------- 2 files changed, 61 insertions(+), 107 deletions(-) diff --git a/payments/db/kv_store_test.go b/payments/db/kv_store_test.go index 76e218e52ec..136d3a73314 100644 --- a/payments/db/kv_store_test.go +++ b/payments/db/kv_store_test.go @@ -35,16 +35,14 @@ func TestKVStoreDeleteDuplicatePayments(t *testing.T) { paymentDB := NewKVTestDB(t) // Create a successful payment. - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) - attempt, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) - require.NoError(t, err) + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) // Init and settle the payment. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to init payment") _, err = paymentDB.RegisterAttempt( @@ -279,11 +277,10 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { ctx := t.Context() // Generate a test payment which does not have duplicates. - noDuplicates, _, err := genInfo(t) - require.NoError(t, err) + noDuplicates, _ := genInfo(t) // Create a new payment entry in the database. - err = paymentDB.InitPayment( + err := paymentDB.InitPayment( ctx, noDuplicates.PaymentIdentifier, noDuplicates, ) require.NoError(t, err) @@ -295,8 +292,7 @@ func TestFetchPaymentWithSequenceNumber(t *testing.T) { require.NoError(t, err) // Generate a test payment which we will add duplicates to. - hasDuplicates, preimg, err := genInfo(t) - require.NoError(t, err) + hasDuplicates, preimg := genInfo(t) // Create a new payment entry in the database. err = paymentDB.InitPayment( @@ -453,8 +449,7 @@ func putDuplicatePayment(t *testing.T, duplicateBucket kvdb.RwBucket, require.NoError(t, err) // Generate fake information for the duplicate payment. - info, _, err := genInfo(t) - require.NoError(t, err) + info, _ := genInfo(t) // Write the payment info to disk under the creation info key. This code // is copied rather than using serializePaymentCreationInfo to ensure @@ -579,10 +574,6 @@ func TestKVStoreQueryPaymentsDuplicates(t *testing.T) { paymentDB := NewKVTestDB(t) - // Initialize the payment database. - paymentDB, err := NewKVStore(paymentDB.db) - require.NoError(t, err) - // Make a preliminary query to make sure it's ok to // query when we have no payments. resp, err := paymentDB.QueryPayments(ctx, tt.query) @@ -600,11 +591,8 @@ func TestKVStoreQueryPaymentsDuplicates(t *testing.T) { for i := 0; i < nonDuplicatePayments; i++ { // Generate a test payment. - info, preimg, err := genInfo(t) - if err != nil { - t.Fatalf("unable to create test "+ - "payment: %v", err) - } + info, preimg := genInfo(t) + // Override creation time to allow for testing // of CreationDateStart and CreationDateEnd. info.CreationTime = time.Unix(int64(i+1), 0) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 668ac4c1d0a..d7dc956daa8 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -130,8 +130,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { attemptID := uint64(0) for i := 0; i < len(payments); i++ { - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) @@ -139,15 +138,14 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { // Set the payment id accordingly in the payments slice. payments[i].id = info.PaymentIdentifier - attempt, err := genAttemptWithHash( + attempt := genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err) attemptID++ // Init the payment. - err = p.InitPayment(ctx, info.PaymentIdentifier, info) + err := p.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") // Register and fail the first attempt for all payments. @@ -169,10 +167,9 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { // Depending on the test case, fail or succeed the next // attempt. - attempt, err = genAttemptWithHash( + attempt = genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err) attemptID++ _, err = p.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) @@ -182,7 +179,7 @@ func createTestPayments(t *testing.T, p DB, payments []*payment) { // Fail the attempt and the payment overall. case StatusFailed: htlcFailure := HTLCFailUnreadable - _, err = p.FailAttempt( + _, err := p.FailAttempt( ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCFailInfo{ Reason: htlcFailure, @@ -363,15 +360,14 @@ func assertDBPayments(t *testing.T, paymentDB DB, payments []*payment) { } // genPreimage generates a random preimage. -func genPreimage(t *testing.T) (lntypes.Preimage, error) { +func genPreimage(t *testing.T) lntypes.Preimage { t.Helper() var preimage [32]byte - if _, err := io.ReadFull(rand.Reader, preimage[:]); err != nil { - return preimage, err - } + _, err := io.ReadFull(rand.Reader, preimage[:]) + require.NoError(t, err, "unable to generate preimage") - return preimage, nil + return preimage } // genSessionKey generates a new random private key for use as a session key. @@ -410,24 +406,22 @@ func genPaymentCreationInfo(t *testing.T, } // genPreimageAndHash generates a random preimage and its corresponding hash. -func genPreimageAndHash(t *testing.T) (lntypes.Preimage, lntypes.Hash, error) { +func genPreimageAndHash(t *testing.T) (lntypes.Preimage, lntypes.Hash) { t.Helper() - preimage, err := genPreimage(t) - require.NoError(t, err) + preimage := genPreimage(t) rhash := sha256.Sum256(preimage[:]) var hash lntypes.Hash copy(hash[:], rhash[:]) - return preimage, hash, nil + return preimage, hash } // genAttemptWithPreimage generates an HTLC attempt and returns both the // attempt and preimage. func genAttemptWithHash(t *testing.T, attemptID uint64, - sessionKey *btcec.PrivateKey, hash lntypes.Hash) (*HTLCAttemptInfo, - error) { + sessionKey *btcec.PrivateKey, hash lntypes.Hash) *HTLCAttemptInfo { t.Helper() @@ -435,24 +429,21 @@ func genAttemptWithHash(t *testing.T, attemptID uint64, attemptID, sessionKey, *testRoute.Copy(), time.Time{}, &hash, ) - if err != nil { - return nil, err - } + require.NoError(t, err, "unable to generate htlc attempt") - return &attempt.HTLCAttemptInfo, nil + return &attempt.HTLCAttemptInfo } // genInfo generates a payment creation info and the corresponding preimage. -func genInfo(t *testing.T) (*PaymentCreationInfo, lntypes.Preimage, error) { - preimage, _, err := genPreimageAndHash(t) - if err != nil { - return nil, preimage, err - } +func genInfo(t *testing.T) (*PaymentCreationInfo, lntypes.Preimage) { + t.Helper() + + preimage, _ := genPreimageAndHash(t) rhash := sha256.Sum256(preimage[:]) creationInfo := genPaymentCreationInfo(t, rhash) - return creationInfo, preimage, nil + return creationInfo, preimage } // TestDeleteFailedAttempts checks that DeleteFailedAttempts properly removes @@ -534,21 +525,19 @@ func TestMPPRecordValidation(t *testing.T) { paymentDB, _ := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) attemptID := uint64(0) - attempt, err := genAttemptWithHash( + attempt := genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err, "unable to generate htlc message") // Init the payment. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") // Create three unique attempts we'll use for the test, and @@ -566,10 +555,9 @@ func TestMPPRecordValidation(t *testing.T) { // Now try to register a non-MPP attempt, which should fail. attemptID++ - attempt2, err := genAttemptWithHash( + attempt2 := genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err) attempt2.Route.FinalHop().MPP = nil @@ -598,19 +586,15 @@ func TestMPPRecordValidation(t *testing.T) { // Create and init a new payment. This time we'll check that we cannot // register an MPP attempt if we already registered a non-MPP one. - preimg, err = genPreimage(t) - require.NoError(t, err) + preimg = genPreimage(t) rhash = sha256.Sum256(preimg[:]) info = genPaymentCreationInfo(t, rhash) attemptID++ - attempt, err = genAttemptWithHash( + attempt = genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err) - - require.NoError(t, err, "unable to generate htlc message") err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") @@ -623,10 +607,9 @@ func TestMPPRecordValidation(t *testing.T) { // Attempt to register an MPP attempt, which should fail. attemptID++ - attempt2, err = genAttemptWithHash( + attempt2 = genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err) attempt2.Route.FinalHop().MPP = record.NewMPP( info.Value, [32]byte{1}, @@ -1604,14 +1587,13 @@ func TestSuccessesWithoutInFlight(t *testing.T) { paymentDB, _ := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) // Attempt to complete the payment should fail. - _, err = paymentDB.SettleAttempt( + _, err := paymentDB.SettleAttempt( t.Context(), info.PaymentIdentifier, 0, &HTLCSettleInfo{ @@ -1628,14 +1610,13 @@ func TestFailsWithoutInFlight(t *testing.T) { paymentDB, _ := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) // Calling Fail should return an error. - _, err = paymentDB.Fail( + _, err := paymentDB.Fail( t.Context(), info.PaymentIdentifier, FailureReasonNoRoute, ) require.ErrorIs(t, err, ErrPaymentNotInitiated) @@ -1732,15 +1713,13 @@ func TestDeleteNonInFlight(t *testing.T) { } for _, p := range payments { - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) - attempt, err := genAttemptWithHash( + attempt := genAttemptWithHash( t, attemptID, genSessionKey(t), rhash, ) - require.NoError(t, err) // After generating the attempt, increment the attempt ID to // have unique attempt IDs for each attempt otherwise the unique @@ -1748,7 +1727,7 @@ func TestDeleteNonInFlight(t *testing.T) { attemptID++ // Init payment which initiates StatusInFlight. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to init payment") _, err = paymentDB.RegisterAttempt( @@ -1872,17 +1851,15 @@ func TestSwitchDoubleSend(t *testing.T) { paymentDB, harness := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) - attempt, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) - require.NoError(t, err) + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) // Sends base htlc message which initiate base status and move it to // StatusInFlight and verifies that it was changed. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") harness.AssertPaymentIndex(t, info.PaymentIdentifier) @@ -1952,16 +1929,14 @@ func TestSwitchFail(t *testing.T) { paymentDB, harness := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) - attempt, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) - require.NoError(t, err) + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) // Sends base htlc message which initiate StatusInFlight. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err, "unable to send htlc message") harness.AssertPaymentIndex(t, info.PaymentIdentifier) @@ -2036,7 +2011,7 @@ func TestSwitchFail(t *testing.T) { assertPaymentInfo(t, paymentDB, info.PaymentIdentifier, info, nil, htlc) // Record another attempt. - attempt, err = genAttemptWithHash( + attempt = genAttemptWithHash( t, 1, genSessionKey(t), rhash, ) require.NoError(t, err) @@ -2120,14 +2095,13 @@ func TestMultiShard(t *testing.T) { runSubTest := func(t *testing.T, test testCase) { paymentDB, harness := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) // Init the payment, moving it to the StatusInFlight state. - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err) harness.AssertPaymentIndex(t, info.PaymentIdentifier) @@ -2146,10 +2120,9 @@ func TestMultiShard(t *testing.T) { var attempts []*HTLCAttemptInfo for i := uint64(0); i < 3; i++ { - a, err := genAttemptWithHash( + a := genAttemptWithHash( t, i, genSessionKey(t), rhash, ) - require.NoError(t, err) a.Route.FinalHop().AmtToForward = shardAmt a.Route.FinalHop().MPP = record.NewMPP( @@ -2181,10 +2154,9 @@ func TestMultiShard(t *testing.T) { // For a fourth attempt, check that attempting to // register it will fail since the total sent amount // will be too large. - b, err := genAttemptWithHash( + b := genAttemptWithHash( t, 3, genSessionKey(t), rhash, ) - require.NoError(t, err) b.Route.FinalHop().AmtToForward = shardAmt b.Route.FinalHop().MPP = record.NewMPP( @@ -2290,10 +2262,9 @@ func TestMultiShard(t *testing.T) { // Try to register yet another attempt. This should fail now // that the payment has reached a terminal condition. - b, err = genAttemptWithHash( + b = genAttemptWithHash( t, 3, genSessionKey(t), rhash, ) - require.NoError(t, err) b.Route.FinalHop().AmtToForward = shardAmt b.Route.FinalHop().MPP = record.NewMPP( @@ -2752,8 +2723,7 @@ func TestQueryPayments(t *testing.T) { // First, create all payments. for i := range numberOfPayments { // Generate a test payment. - info, _, err := genInfo(t) - require.NoError(t, err) + info, _ := genInfo(t) // Override creation time to allow for testing // of CreationDateStart and CreationDateEnd. @@ -2941,8 +2911,7 @@ func TestFetchInFlightPayments(t *testing.T) { require.Contains(t, inFlightHashes, payments[3].id) // Now settle one of the in-flight payments. - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) _, err = paymentDB.SettleAttempt( ctx, payments[2].id, 5, @@ -2974,28 +2943,25 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { paymentDB, _ := NewTestDB(t) - preimg, err := genPreimage(t) - require.NoError(t, err) + preimg := genPreimage(t) rhash := sha256.Sum256(preimg[:]) info := genPaymentCreationInfo(t, rhash) // Init payment with double the amount to allow two attempts. info.Value *= 2 - err = paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) require.NoError(t, err) // Register two attempts for the same payment. - attempt1, err := genAttemptWithHash(t, 0, genSessionKey(t), rhash) - require.NoError(t, err) + attempt1 := genAttemptWithHash(t, 0, genSessionKey(t), rhash) _, err = paymentDB.RegisterAttempt( ctx, info.PaymentIdentifier, attempt1, ) require.NoError(t, err) - attempt2, err := genAttemptWithHash(t, 1, genSessionKey(t), rhash) - require.NoError(t, err) + attempt2 := genAttemptWithHash(t, 1, genSessionKey(t), rhash) _, err = paymentDB.RegisterAttempt( ctx, info.PaymentIdentifier, attempt2, From a0a22b7ff914a25851810e4ef011e5b1a310d74d Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 24 Nov 2025 19:15:13 +0100 Subject: [PATCH 72/78] paymentsdb: add additional test for first hop data We add a test which tests the retrieval of first hop data like the first hop amount or the custom records on the route level. --- payments/db/payment_test.go | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index d7dc956daa8..3643ac0a385 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -18,6 +18,7 @@ import ( "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -2984,3 +2985,64 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { // Verify the payment has both attempts. require.Len(t, inFlightPayments[0].HTLCs, 2) } + +// TestRouteFirstHopData tests that Route.FirstHopAmount and +// Route.FirstHopWireCustomRecords are correctly stored and retrieved. +func TestRouteFirstHopData(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + firstHopAmount := lnwire.MilliSatoshi(1234) + + // Init payment. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Create an attempt with both FirstHopAmount and + // FirstHopWireCustomRecords set on the route. + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + attempt.Route.FirstHopAmount = tlv.NewRecordT[tlv.TlvType0]( + tlv.NewBigSizeT(firstHopAmount), + ) + typeIdx1 := uint64(lnwire.MinCustomRecordsTlvType + 10) + typeIdx2 := uint64(lnwire.MinCustomRecordsTlvType + 20) + attempt.Route.FirstHopWireCustomRecords = lnwire.CustomRecords{ + typeIdx1: []byte("wire_record_1"), + typeIdx2: []byte("wire_record_2"), + } + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + // Fetch the payment and verify first hop data was stored. + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + + require.Len(t, payment.HTLCs, 1) + htlc := payment.HTLCs[0] + + // Verify the FirstHopAmount matches what we set. + require.NotNil(t, htlc.Route.FirstHopAmount) + require.Equal( + t, firstHopAmount, + htlc.Route.FirstHopAmount.Val.Int(), + ) + + // Verify the FirstHopWireCustomRecords match what we set. + require.NotEmpty(t, htlc.Route.FirstHopWireCustomRecords) + require.Len(t, htlc.Route.FirstHopWireCustomRecords, 2) + require.Equal( + t, []byte("wire_record_1"), + htlc.Route.FirstHopWireCustomRecords[typeIdx1], + ) + require.Equal( + t, []byte("wire_record_2"), + htlc.Route.FirstHopWireCustomRecords[typeIdx2], + ) +} From 372fbbd70b92c0802b9f37454294ea7eb76bdd3d Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 24 Nov 2025 19:42:46 +0100 Subject: [PATCH 73/78] paymentsdb: add more unit tests to increase coverage We add a couple of additional tests to increase the unit test coverage of the sql store but also the kv store. We only create db agnostic unit tests so both backends are tested effectively. --- payments/db/payment_test.go | 303 ++++++++++++++++++++++++++++++++++-- 1 file changed, 294 insertions(+), 9 deletions(-) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 3643ac0a385..083999e9f6d 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -82,23 +82,24 @@ var ( SourcePubKey: vertex, Hops: []*route.Hop{ { - PubKeyBytes: vertex, - ChannelID: 9876, - OutgoingTimeLock: 120, - AmtToForward: 900, - EncryptedData: []byte{1, 3, 3}, - BlindingPoint: pub, + PubKeyBytes: vertex, + EncryptedData: []byte{1, 3, 3}, + BlindingPoint: pub, }, { PubKeyBytes: vertex, EncryptedData: []byte{3, 2, 1}, }, { + // Final hop must have AmtToForward, + // OutgoingTimeLock, and TotalAmtMsat per + // BOLT spec. We use the correct values here + // although it is not tested in this test. PubKeyBytes: vertex, - Metadata: []byte{4, 5, 6}, - AmtToForward: 500, + EncryptedData: []byte{2, 2, 2}, + AmtToForward: 1000, OutgoingTimeLock: 100, - TotalAmtMsat: 500, + TotalAmtMsat: 1000, }, }, } @@ -3046,3 +3047,287 @@ func TestRouteFirstHopData(t *testing.T) { htlc.Route.FirstHopWireCustomRecords[typeIdx2], ) } + +// TestRegisterAttemptWithAMP tests that AMP data is correctly stored and +// retrieved on route hops. +func TestRegisterAttemptWithAMP(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + // Init payment. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Create a basic attempt, then modify the route to include AMP data. + // This bypasses the route validation in NewHtlcAttempt. + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + + // Add AMP data to the final hop. + rootShare := [32]byte{1, 2, 3, 4} + setID := [32]byte{5, 6, 7, 8} + childIndex := uint32(42) + + finalHopIdx := len(attempt.Route.Hops) - 1 + attempt.Route.Hops[finalHopIdx].AMP = record.NewAMP( + rootShare, setID, childIndex, + ) + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + // Fetch the payment and verify AMP data was stored. + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + + require.Len(t, payment.HTLCs, 1) + htlc := payment.HTLCs[0] + + // Verify the AMP data on the final hop matches what we set. + finalHop := htlc.Route.Hops[finalHopIdx] + require.NotNil(t, finalHop.AMP) + require.Equal(t, rootShare, finalHop.AMP.RootShare()) + require.Equal(t, setID, finalHop.AMP.SetID()) + require.Equal(t, childIndex, finalHop.AMP.ChildIndex()) +} + +// TestRegisterAttemptWithBlindedRoute tests that blinded route data +// (EncryptedData, BlindingPoint, TotalAmtMsat) is correctly stored and +// retrieved. +func TestRegisterAttemptWithBlindedRoute(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + + // Create payment info with amount matching + // testBlindedRoute.TotalAmount. + info := &PaymentCreationInfo{ + PaymentIdentifier: rhash, + Value: testBlindedRoute.TotalAmount, + CreationTime: time.Unix(time.Now().Unix(), 0), + PaymentRequest: []byte("blinded"), + } + + // Init payment. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Create a basic attempt, then replace the route with testBlindedRoute. + // This bypasses the route validation in NewHtlcAttempt. + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + + // Replace with testBlindedRoute which has the correct blinded route + // structure. + attempt.Route = testBlindedRoute + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + // Fetch the payment and verify blinded route data was stored. + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + + require.Len(t, payment.HTLCs, 1) + htlc := payment.HTLCs[0] + + // Verify the blinded route data. + require.Len(t, htlc.Route.Hops, 3) + + // First hop (introduction point) should have BlindingPoint and + // EncryptedData. + hop0 := htlc.Route.Hops[0] + require.Equal(t, []byte{1, 3, 3}, hop0.EncryptedData) + require.NotNil(t, hop0.BlindingPoint) + require.True(t, hop0.BlindingPoint.IsEqual(pub)) + + // Second hop (intermediate) should have only EncryptedData. + hop1 := htlc.Route.Hops[1] + require.Equal(t, []byte{3, 2, 1}, hop1.EncryptedData) + require.Nil(t, hop1.BlindingPoint) + + // Third hop (final) should have EncryptedData, AmtToForward, + // OutgoingTimeLock, and TotalAmtMsat. + hop2 := htlc.Route.Hops[2] + require.Equal(t, []byte{2, 2, 2}, hop2.EncryptedData) + require.Equal(t, lnwire.MilliSatoshi(1000), hop2.AmtToForward) + require.Equal(t, uint32(100), hop2.OutgoingTimeLock) + require.Equal(t, lnwire.MilliSatoshi(1000), hop2.TotalAmtMsat) +} + +// TestFailAttemptWithoutMessage tests that FailAttempt works correctly when +// no failure message is provided. +func TestFailAttemptWithoutMessage(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + // Init payment. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Register an attempt. + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + // Fail the attempt without a failure message (nil Message). + failInfo := &HTLCFailInfo{ + Reason: HTLCFailUnreadable, + FailureSourceIndex: 2, + Message: nil, // No message. + } + + payment, err := paymentDB.FailAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, failInfo, + ) + require.NoError(t, err) + require.NotNil(t, payment) + + // Verify the attempt was failed. + require.Len(t, payment.HTLCs, 1) + htlc := payment.HTLCs[0] + require.NotNil(t, htlc.Failure) + require.Equal(t, HTLCFailUnreadable, htlc.Failure.Reason) + require.Equal(t, uint32(2), htlc.Failure.FailureSourceIndex) + require.Nil(t, htlc.Failure.Message) +} + +// TestFailAttemptWithMessage tests that FailAttempt correctly stores and +// retrieves a failure message. +func TestFailAttemptWithMessage(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + // Init payment. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Register an attempt. + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + // Create a failure message. + failureMsg := lnwire.NewTemporaryChannelFailure(nil) + + // Fail the attempt with a failure message. + failInfo := &HTLCFailInfo{ + Reason: HTLCFailUnreadable, + FailureSourceIndex: 1, + Message: failureMsg, + } + + payment, err := paymentDB.FailAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, failInfo, + ) + require.NoError(t, err) + require.NotNil(t, payment) + + // Verify the attempt was failed. + require.Len(t, payment.HTLCs, 1) + htlc := payment.HTLCs[0] + require.NotNil(t, htlc.Failure) + require.Equal(t, HTLCFailUnreadable, htlc.Failure.Reason) +} + +// TestFailAttemptOnSucceededPayment tests that FailAttempt returns an error +// when trying to fail an attempt on an already succeeded payment. +func TestFailAttemptOnSucceededPayment(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + // Init payment. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Register an attempt. + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + // Settle the attempt, which makes the payment succeed. + _, err = paymentDB.SettleAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, + &HTLCSettleInfo{Preimage: preimg}, + ) + require.NoError(t, err) + + // Now try to fail the same attempt - this should fail because the + // payment is already succeeded. + failInfo := &HTLCFailInfo{ + Reason: HTLCFailUnreadable, + } + + _, err = paymentDB.FailAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, failInfo, + ) + require.Error(t, err) + require.ErrorIs(t, err, ErrPaymentAlreadySucceeded) +} + +// TestFetchPaymentWithNoAttempts tests that FetchPayment correctly returns a +// payment that has been initialized but has no HTLC attempts yet. This tests +// the early return path in batchLoadPaymentDetailsData when there are no +// attempts. +func TestFetchPaymentWithNoAttempts(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + // Init payment but don't register any attempts. + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + // Fetch the payment - it should have no HTLCs. + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + require.NotNil(t, payment) + + // Verify the payment has no HTLCs. + require.Empty(t, payment.HTLCs) + + // Verify the payment info is correct. + require.Equal(t, info.PaymentIdentifier, payment.Info.PaymentIdentifier) + require.Equal(t, info.Value, payment.Info.Value) + require.Equal(t, StatusInitiated, payment.Status) +} From 2548c151673843ac5c4c2249665b24f41e41eb3e Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 24 Nov 2025 21:57:27 +0100 Subject: [PATCH 74/78] docs: add release-notes --- docs/release-notes/release-notes-0.21.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index 0683f38096e..0679bb98b86 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -215,6 +215,8 @@ db functions Part 1](https://github.com/lightningnetwork/lnd/pull/10307) * [Thread context through payment db functions Part 2](https://github.com/lightningnetwork/lnd/pull/10308) + * [Finalize SQL implementation for + payments db](https://github.com/lightningnetwork/lnd/pull/10373) ## Code Health From a2cb753428df2191672c317367b9f9094e4c8446 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 15 Feb 2026 21:41:26 +0100 Subject: [PATCH 75/78] sqldb: rename 000009_payments to 000010_payments The 000009 schema version slot is now taken by 000009_graph_v2_columns which was merged ahead of the payments schema. Bump the payments schema file number to 000010 to avoid the collision. Moreover update the migration_dev.go file to reflect this change and update the order of migration. --- sqldb/migrations_dev.go | 6 +++--- .../{000009_payments.down.sql => 000010_payments.down.sql} | 0 .../{000009_payments.up.sql => 000010_payments.up.sql} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename sqldb/sqlc/migrations/{000009_payments.down.sql => 000010_payments.down.sql} (100%) rename sqldb/sqlc/migrations/{000009_payments.up.sql => 000010_payments.up.sql} (100%) diff --git a/sqldb/migrations_dev.go b/sqldb/migrations_dev.go index 4158cb94903..ca57cf0f111 100644 --- a/sqldb/migrations_dev.go +++ b/sqldb/migrations_dev.go @@ -4,8 +4,8 @@ package sqldb var migrationAdditions = []MigrationConfig{ { - Name: "000009_payments", - Version: 11, - SchemaVersion: 9, + Name: "000010_payments", + Version: 12, + SchemaVersion: 10, }, } diff --git a/sqldb/sqlc/migrations/000009_payments.down.sql b/sqldb/sqlc/migrations/000010_payments.down.sql similarity index 100% rename from sqldb/sqlc/migrations/000009_payments.down.sql rename to sqldb/sqlc/migrations/000010_payments.down.sql diff --git a/sqldb/sqlc/migrations/000009_payments.up.sql b/sqldb/sqlc/migrations/000010_payments.up.sql similarity index 100% rename from sqldb/sqlc/migrations/000009_payments.up.sql rename to sqldb/sqlc/migrations/000010_payments.up.sql From 0c2951aa05e450e58cd66ee300258e5ed4f60560 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 24 Feb 2026 09:44:59 +0100 Subject: [PATCH 76/78] paymentsdb: fix SettleAttempt and FailAttempt to use caller-provided timestamps The SQL backend introduced in this PR was ignoring the SettleTime and FailTime fields provided in HTLCSettleInfo and HTLCFailInfo, instead always recording time.Now() as the resolution timestamp. The KV backend correctly serializes and deserializes these fields. The timestamps are set by the caller using a mockable clock (p.router.cfg.Clock.Now() in payment_lifecycle.go), so ignoring them means the stored timestamp reflects when the DB write happened rather than when the event occurred, breaking deterministic testing. This commit also extends the test assertions in assertPaymentInfo to verify that SettleTime and FailTime are correctly stored and retrieved by the SQL backend, and updates the relevant call sites to pass explicit timestamps so regressions are caught. --- payments/db/payment_test.go | 131 +++++++++++++++++++++--------------- payments/db/sql_store.go | 4 +- 2 files changed, 78 insertions(+), 57 deletions(-) diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 083999e9f6d..55a5c0966e3 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -109,8 +109,10 @@ var ( // attempt, including whether it was settled or failed. type htlcStatus struct { *HTLCAttemptInfo - settle *lntypes.Preimage - failure *HTLCFailReason + settle *lntypes.Preimage + settleTime time.Time + failure *HTLCFailReason + failTime time.Time } // payment is a helper structure that holds basic information on a test payment, @@ -232,71 +234,71 @@ func assertRouteEqual(t *testing.T, a, b *route.Route) error { // assertPaymentInfo retrieves the payment referred to by hash and verifies the // expected values. func assertPaymentInfo(t *testing.T, p DB, hash lntypes.Hash, - c *PaymentCreationInfo, f *FailureReason, - a *htlcStatus) { + c *PaymentCreationInfo, f *FailureReason, a *htlcStatus) { t.Helper() - ctx := t.Context() - - payment, err := p.FetchPayment(ctx, hash) - if err != nil { - t.Fatal(err) - } - - if !reflect.DeepEqual(payment.Info, c) { - t.Fatalf("PaymentCreationInfos don't match: %v vs %v", - spew.Sdump(payment.Info), spew.Sdump(c)) - } + payment, err := p.FetchPayment(t.Context(), hash) + require.NoError(t, err) + require.Equal(t, c, payment.Info, "PaymentCreationInfos don't match") if f != nil { - if *payment.FailureReason != *f { - t.Fatal("unexpected failure reason") - } + require.NotNil( + t, payment.FailureReason, "expected failure reason", + ) + require.Equal( + t, *f, *payment.FailureReason, + "unexpected failure reason", + ) } else { - if payment.FailureReason != nil { - t.Fatal("unexpected failure reason") - } + require.Nil( + t, payment.FailureReason, "expected no failure reason", + ) } if a == nil { - if len(payment.HTLCs) > 0 { - t.Fatal("expected no htlcs") - } - + require.Empty(t, payment.HTLCs, "expected no htlcs") return } + require.GreaterOrEqual(t, len(payment.HTLCs), int(a.AttemptID)+1, + "HTLC with attempt ID %v not found", a.AttemptID) htlc := payment.HTLCs[a.AttemptID] - if err := assertRouteEqual(t, &htlc.Route, &a.Route); err != nil { - t.Fatal("routes do not match") - } + require.NoError(t, assertRouteEqual(t, &htlc.Route, &a.Route), + "routes do not match") + require.Equal(t, a.AttemptID, htlc.AttemptID, "unexpected attempt ID") - if htlc.AttemptID != a.AttemptID { - t.Fatalf("unnexpected attempt ID %v, expected %v", - htlc.AttemptID, a.AttemptID) + if a.failure != nil { + require.NotNil(t, htlc.Failure, "expected HTLC to be failed") + require.Equal(t, *a.failure, htlc.Failure.Reason, + "expected HTLC failure") + } else { + require.Nil(t, htlc.Failure, "expected no HTLC failure") } - if a.failure != nil { - if htlc.Failure == nil { - t.Fatalf("expected HTLC to be failed") - } + if a.settle != nil { + require.Equal( + t, *a.settle, htlc.Settle.Preimage, + "expected HTLC settle preimage", + ) + } else { + require.Nil(t, htlc.Settle, "expected no settle info") + } - if htlc.Failure.Reason != *a.failure { - t.Fatalf("expected HTLC failure %v, had %v", - *a.failure, htlc.Failure.Reason) - } - } else if htlc.Failure != nil { - t.Fatalf("expected no HTLC failure") + if !a.settleTime.IsZero() { + // Normalize to UTC to ensure consistent timezone comparison. + require.Equal( + t, a.settleTime.UTC(), htlc.Settle.SettleTime.UTC(), + "SettleTimes don't match", + ) } - if a.settle != nil { - if htlc.Settle.Preimage != *a.settle { - t.Fatalf("Preimages don't match: %x vs %x", - htlc.Settle.Preimage, a.settle) - } - } else if htlc.Settle != nil { - t.Fatal("expected no settle info") + if !a.failTime.IsZero() { + // Normalize to UTC to ensure consistent timezone comparison. + require.Equal( + t, htlc.Failure.FailTime.UTC(), a.failTime.UTC(), + "FailTimes don't match", + ) } } @@ -1992,10 +1994,12 @@ func TestSwitchFail(t *testing.T) { require.NoError(t, err, "unable to register attempt") htlcReason := HTLCFailUnreadable + htlcFailTime := time.Unix(5000, 0) _, err = paymentDB.FailAttempt( ctx, info.PaymentIdentifier, attempt.AttemptID, &HTLCFailInfo{ - Reason: htlcReason, + Reason: htlcReason, + FailTime: htlcFailTime, }, ) if err != nil { @@ -2008,6 +2012,7 @@ func TestSwitchFail(t *testing.T) { htlc := &htlcStatus{ HTLCAttemptInfo: attempt, failure: &htlcReason, + failTime: htlcFailTime, } assertPaymentInfo(t, paymentDB, info.PaymentIdentifier, info, nil, htlc) @@ -2173,10 +2178,12 @@ func TestMultiShard(t *testing.T) { // Fail the second attempt. a := attempts[1] htlcFail := HTLCFailUnreadable + secondFailTime := time.Unix(6000, 0) _, err = paymentDB.FailAttempt( ctx, info.PaymentIdentifier, a.AttemptID, &HTLCFailInfo{ - Reason: htlcFail, + Reason: htlcFail, + FailTime: secondFailTime, }, ) if err != nil { @@ -2186,6 +2193,7 @@ func TestMultiShard(t *testing.T) { htlc := &htlcStatus{ HTLCAttemptInfo: a, failure: &htlcFail, + failTime: secondFailTime, } assertPaymentInfo( t, paymentDB, info.PaymentIdentifier, info, nil, htlc, @@ -2204,10 +2212,12 @@ func TestMultiShard(t *testing.T) { var firstFailReason *FailureReason if test.settleFirst { + firstSettleTime := time.Unix(1000, 0) _, err := paymentDB.SettleAttempt( ctx, info.PaymentIdentifier, a.AttemptID, &HTLCSettleInfo{ - Preimage: preimg, + Preimage: preimg, + SettleTime: firstSettleTime, }, ) if err != nil { @@ -2215,17 +2225,21 @@ func TestMultiShard(t *testing.T) { "received, got: %v", err) } - // Assert that the HTLC has had the preimage recorded. + // Assert that the HTLC has had the preimage and + // settle time recorded. htlc.settle = &preimg + htlc.settleTime = firstSettleTime assertPaymentInfo( t, paymentDB, info.PaymentIdentifier, info, nil, htlc, ) } else { + firstFailTime := time.Unix(2000, 0) _, err := paymentDB.FailAttempt( ctx, info.PaymentIdentifier, a.AttemptID, &HTLCFailInfo{ - Reason: htlcFail, + Reason: htlcFail, + FailTime: firstFailTime, }, ) if err != nil { @@ -2235,6 +2249,7 @@ func TestMultiShard(t *testing.T) { // Assert the failure was recorded. htlc.failure = &htlcFail + htlc.failTime = firstFailTime assertPaymentInfo( t, paymentDB, info.PaymentIdentifier, info, nil, htlc, @@ -2297,25 +2312,30 @@ func TestMultiShard(t *testing.T) { } if test.settleLast { // Settle the last outstanding attempt. + lastSettleTime := time.Unix(3000, 0) _, err = paymentDB.SettleAttempt( ctx, info.PaymentIdentifier, a.AttemptID, &HTLCSettleInfo{ - Preimage: preimg, + Preimage: preimg, + SettleTime: lastSettleTime, }, ) require.NoError(t, err, "unable to settle") htlc.settle = &preimg + htlc.settleTime = lastSettleTime assertPaymentInfo( t, paymentDB, info.PaymentIdentifier, info, firstFailReason, htlc, ) } else { // Fail the attempt. + lastFailTime := time.Unix(4000, 0) _, err := paymentDB.FailAttempt( ctx, info.PaymentIdentifier, a.AttemptID, &HTLCFailInfo{ - Reason: htlcFail, + Reason: htlcFail, + FailTime: lastFailTime, }, ) if err != nil { @@ -2325,6 +2345,7 @@ func TestMultiShard(t *testing.T) { // Assert the failure was recorded. htlc.failure = &htlcFail + htlc.failTime = lastFailTime assertPaymentInfo( t, paymentDB, info.PaymentIdentifier, info, firstFailReason, htlc, diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 1c6e3042a93..2589f3e7942 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -1629,7 +1629,7 @@ func (s *SQLStore) SettleAttempt(ctx context.Context, paymentHash lntypes.Hash, err = db.SettleAttempt(ctx, sqlc.SettleAttemptParams{ AttemptIndex: int64(attemptID), - ResolutionTime: time.Now(), + ResolutionTime: settleInfo.SettleTime.UTC(), ResolutionType: int32(HTLCAttemptResolutionSettled), SettlePreimage: settleInfo.Preimage[:], }) @@ -1716,7 +1716,7 @@ func (s *SQLStore) FailAttempt(ctx context.Context, paymentHash lntypes.Hash, err = db.FailAttempt(ctx, sqlc.FailAttemptParams{ AttemptIndex: int64(attemptID), - ResolutionTime: time.Now(), + ResolutionTime: failInfo.FailTime.UTC(), ResolutionType: int32(HTLCAttemptResolutionFailed), FailureSourceIndex: sqldb.SQLInt32( failInfo.FailureSourceIndex, From 216de55dff8de52a686f65c1245e3f9acbc426b7 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 24 Feb 2026 10:01:06 +0100 Subject: [PATCH 77/78] paymentsdb: sort FetchInFlightPayments result by sequence number The SQL implementation collects payments into a map before converting to a slice, resulting in non-deterministic iteration order due to Go's intentional map randomisation. Sort the result by SequenceNum to produce a deterministic, insertion-ordered output. Note that the current sole caller (resumePayments in router.go) processes each payment independently, so this ordering does not affect any existing behaviour. --- payments/db/sql_store.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 2589f3e7942..9644266f823 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "math" + "sort" "strconv" "time" @@ -1060,11 +1061,16 @@ func (s *SQLStore) FetchInFlightPayments(ctx context.Context) ([]*MPPayment, return err } - // Convert map to slice. + // Convert map to slice and sort by sequence number to + // produce a deterministic ordering. mpPayments = make([]*MPPayment, 0, len(processedPayments)) for _, payment := range processedPayments { mpPayments = append(mpPayments, payment) } + sort.Slice(mpPayments, func(i, j int) bool { + return mpPayments[i].SequenceNum < + mpPayments[j].SequenceNum + }) return nil }, func() { From e9a88267ff52c3f1beca97b9d449bfdb6350ab5a Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 24 Feb 2026 16:40:40 +0100 Subject: [PATCH 78/78] paymentsdb: fix duplicate interface check and down migration drop order - Remove duplicate compile-time interface assertion for SQLStore. - Fix the down migration to drop payment_intents before payments to respect the foreign key dependency order. This was not a bug in the first place bc we have the CASCADE when deleting payments. --- payments/db/sql_store.go | 3 --- sqldb/sqlc/migrations/000010_payments.down.sql | 12 ++++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 9644266f823..3637a981bc5 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -132,9 +132,6 @@ func NewSQLStore(cfg *SQLStoreConfig, db BatchedSQLQueries, }, nil } -// A compile-time constraint to ensure SQLStore implements DB. -var _ DB = (*SQLStore)(nil) - // fetchPaymentWithCompleteData fetches a payment with all its related data // including attempts, hops, and custom records from the database. // This is a convenience wrapper around the batch loading functions for single diff --git a/sqldb/sqlc/migrations/000010_payments.down.sql b/sqldb/sqlc/migrations/000010_payments.down.sql index 62b19cb991e..68f19f77960 100644 --- a/sqldb/sqlc/migrations/000010_payments.down.sql +++ b/sqldb/sqlc/migrations/000010_payments.down.sql @@ -40,15 +40,15 @@ DROP INDEX IF EXISTS idx_htlc_attempt_time; DROP TABLE IF EXISTS payment_htlc_attempts; -- ───────────────────────────────────────────── --- Drop payments table and its indexes. +-- Drop payment intents table and its indexes. -- ───────────────────────────────────────────── -DROP INDEX IF EXISTS idx_payments_created_at; -DROP TABLE IF EXISTS payments; +DROP INDEX IF EXISTS idx_payment_intents_type; +DROP TABLE IF EXISTS payment_intents; -- ───────────────────────────────────────────── --- Drop payment intents table and its indexes. +-- Drop payments table and its indexes. -- ───────────────────────────────────────────── -DROP INDEX IF EXISTS idx_payment_intents_type; -DROP TABLE IF EXISTS payment_intents; \ No newline at end of file +DROP INDEX IF EXISTS idx_payments_created_at; +DROP TABLE IF EXISTS payments; \ No newline at end of file