diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index b1e3d865208..b1170f23bbb 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -66,6 +66,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 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/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 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/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) { diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 1b7bfbacb7b..8030a8694e5 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" ) @@ -37,7 +39,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. @@ -49,12 +51,37 @@ 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) 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. + */ + InsertPaymentIntent(ctx context.Context, arg sqlc.InsertPaymentIntentParams) (int64, 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) + 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 + + // 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 @@ -151,7 +178,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 +212,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 +245,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 +277,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 +311,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 +341,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) { @@ -695,3 +728,628 @@ 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 +} 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 6a4c9dd33b2..97df2d6f6c5 100644 --- a/sqldb/sqlc/models.go +++ b/sqldb/sqlc/models.go @@ -205,7 +205,6 @@ type MigrationTracker struct { type Payment struct { ID int64 - IntentID sql.NullInt64 AmountMsat int64 CreatedAt time.Time PaymentIdentifier []byte @@ -258,6 +257,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..fb117bcaad0 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -23,6 +23,68 @@ 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 +) +` + +// 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 +} + +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 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, @@ -222,6 +284,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, @@ -317,11 +412,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 +431,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 +492,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 +527,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 +603,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 +665,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, @@ -592,3 +684,353 @@ 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. +// 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 + 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 fd0d3eaff5f..96cfc11290b 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -21,20 +21,27 @@ 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 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) + 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 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) @@ -112,6 +119,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) @@ -125,6 +133,19 @@ 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. + // 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 + 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) IsZombieChannel(ctx context.Context, arg IsZombieChannelParams) (bool, error) @@ -144,6 +165,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 ce43a3e2976..b0183ccd2a6 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 @@ -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 @@ -151,3 +160,195 @@ 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 +); + +-- 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 +);