From 04da2fa5835baef6392ab71d14ab613d37e064ab Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 20 Jul 2026 10:07:20 -0300 Subject: [PATCH] paymentsdb: normalize orphaned blinded total The KV route format stores blinded fields independently. Routes accepted through SendToRouteV2 could therefore contain a blinded total amount without encrypted recipient data. The SQL migration treated the total as proof of a blinded hop and bound nil to the required encrypted-data column, preventing LND from starting. Use encrypted recipient data as the blinded-hop discriminator and normalize only the known total-only case. Reject blinding-point-only records with payment, attempt and hop context instead of exposing an opaque SQL constraint error. Log normalized totals, account for them during migration validation, and cover both cases with regression tests. --- .../db/migration1/migration_validation.go | 21 ++- payments/db/migration1/sql_migration.go | 43 ++++- payments/db/migration1/sql_migration_test.go | 149 +++++++++++++++++- 3 files changed, 202 insertions(+), 11 deletions(-) diff --git a/payments/db/migration1/migration_validation.go b/payments/db/migration1/migration_validation.go index c81303e54..7b239d2d1 100644 --- a/payments/db/migration1/migration_validation.go +++ b/payments/db/migration1/migration_validation.go @@ -394,11 +394,26 @@ func normalizePaymentForCompare(payment *MPPayment) { } for j := range htlc.Route.Hops { - if len(htlc.Route.Hops[j].CustomRecords) == 0 { - htlc.Route.Hops[j].CustomRecords = + hop := htlc.Route.Hops[j] + if len(hop.CustomRecords) == 0 { + hop.CustomRecords = record.CustomSet{} } + // The migration treats nil and empty encrypted data as + // absent, so it omits the blinded child row. SQL reads + // the hop back with nil encrypted data and a zero + // total. Apply the same transformation to the KV + // copy before comparing the payments. A blinding point + // without data is rejected during migration, so it + // cannot reach this comparison. + if len(hop.EncryptedData) == 0 && + hop.BlindingPoint == nil { + + hop.EncryptedData = nil + hop.TotalAmtMsat = 0 + } + // LegacyPayload was a hint used by the KV store to // determine how to serialize and deserialize the hop // payload (i.e. whether to use the legacy format or @@ -406,7 +421,7 @@ func normalizePaymentForCompare(payment *MPPayment) { // all — each field is stored natively in its own // column — so this flag has no meaning there and is // never persisted. - htlc.Route.Hops[j].LegacyPayload = false + hop.LegacyPayload = false } } } diff --git a/payments/db/migration1/sql_migration.go b/payments/db/migration1/sql_migration.go index a35376650..9e187b829 100644 --- a/payments/db/migration1/sql_migration.go +++ b/payments/db/migration1/sql_migration.go @@ -734,8 +734,12 @@ func migrateHTLCAttempt(ctx context.Context, paymentID int64, // Insert route hops. for hopIndex := range htlc.Route.Hops { hop := htlc.Route.Hops[hopIndex] + + // Use the parent hash for diagnostics. For AMP payments this + // is the set ID, which identifies the payment containing the + // shard. err = migrateRouteHop( - ctx, attemptIndex, hopIndex, hop, + ctx, parentPaymentHash, attemptIndex, hopIndex, hop, sqlDB, stats, ) if err != nil { @@ -807,8 +811,8 @@ func migrateHTLCAttempt(ctx context.Context, paymentID int64, // migrateRouteHop migrates a single route hop. func migrateRouteHop(ctx context.Context, - attemptID int64, hopIndex int, hop *Hop, sqlDB SQLQueries, - stats *MigrationStats) error { + parentPaymentHash lntypes.Hash, attemptIndex int64, hopIndex int, + hop *Hop, sqlDB SQLQueries, stats *MigrationStats) error { // Convert channel ID to string representation of uint64. // The SCID is stored as a decimal string to match the converter @@ -817,7 +821,7 @@ func migrateRouteHop(ctx context.Context, // Insert route hop. hopID, err := sqlDB.InsertRouteHop(ctx, sqlc.InsertRouteHopParams{ - HtlcAttemptIndex: attemptID, + HtlcAttemptIndex: attemptIndex, HopIndex: int32(hopIndex), PubKey: hop.PubKeyBytes[:], Scid: scidStr, @@ -829,10 +833,35 @@ func migrateRouteHop(ctx context.Context, return fmt.Errorf("insert hop: %w", err) } - // Check for blinded route data (route blinding). - if len(hop.EncryptedData) > 0 || hop.BlindingPoint != nil || - hop.TotalAmtMsat != 0 { + // Non-empty encrypted recipient data identifies a blinded hop. + // The RPC boundary has required encrypted data with a blinding point + // since these fields were introduced. Internally built blinded routes + // also always contain both. Unlike an orphaned total, a point without + // encrypted data is not a supported legacy encoding. Report it as + // malformed instead of silently discarding it. Keep this rejection in + // sync with normalizePaymentForCompare, which only normalizes hops + // without a blinding point. + hasEncryptedData := len(hop.EncryptedData) > 0 + if !hasEncryptedData && hop.BlindingPoint != nil { + return fmt.Errorf("invalid blinded hop: payment_hash=%x, "+ + "attempt_index=%d, hop=%d: blinding point requires "+ + "encrypted recipient data", parentPaymentHash[:8], + attemptIndex, hopIndex) + } + // SendToRouteV2 historically allowed a blinded total amount without + // blinded hop data. Omit such an orphaned total rather than creating a + // blinded-hop row. + if !hasEncryptedData && hop.TotalAmtMsat != 0 { + log.Warnf("Ignoring orphaned blinded total amount: "+ + "payment_hash=%x, attempt_index=%d, hop=%d, "+ + "total_amt_msat=%d", parentPaymentHash[:8], + attemptIndex, hopIndex, hop.TotalAmtMsat) + } + + // The blinding point and total amount are only associated fields. Use + // the length so nil and empty encrypted data are handled consistently. + if hasEncryptedData { var blindingPoint []byte if hop.BlindingPoint != nil { blindingPoint = hop.BlindingPoint.SerializeCompressed() diff --git a/payments/db/migration1/sql_migration_test.go b/payments/db/migration1/sql_migration_test.go index 05b4d12f4..295f2302a 100644 --- a/payments/db/migration1/sql_migration_test.go +++ b/payments/db/migration1/sql_migration_test.go @@ -940,6 +940,147 @@ func TestMigratePaymentWithBlindedRoute(t *testing.T) { assertPaymentDataMatches(t, ctx, kvDB, sqlStore, paymentHash) } +// TestMigrateOrphanedBlindedTotalAmount tests that a blinded total amount +// without encrypted recipient data is normalized during migration. +func TestMigrateOrphanedBlindedTotalAmount(t *testing.T) { + t.Parallel() + + runTest := func(t *testing.T, hashString string, + encryptedData []byte) { + + t.Helper() + + ctx := context.Background() + kvDB := setupTestKVDB(t) + + var paymentHash [32]byte + copy(paymentHash[:], []byte(hashString)) + + err := kvdb.Update(kvDB, func(tx kvdb.RwTx) error { + paymentsBucket, err := tx.CreateTopLevelBucket( + paymentsRootBucket, + ) + if err != nil { + return err + } + + indexBucket, err := tx.CreateTopLevelBucket( + paymentsIndexBucket, + ) + if err != nil { + return err + } + + return createTestPayment( + t, paymentsBucket, indexBucket, + paymentTestConfig{ + hash: paymentHash, + seqNum: 1, + value: 120000, + creationTime: time.Unix(1, 0), + paymentRequest: hashString, + attemptID: 1, + numHops: 1, + baseChannelID: 400000, + baseTimeLock: 800000, + hopConfigurator: func(hop *Hop, _ int, + _ bool) { + + hop.EncryptedData = encryptedData + hop.TotalAmtMsat = 119400 + }, + }, + ) + }, func() {}) + require.NoError(t, err) + + sqlStore := setupTestSQLDB(t) + err = runPaymentsMigration(ctx, kvDB, sqlStore) + require.NoError(t, err) + + var hash lntypes.Hash + copy(hash[:], paymentHash[:]) + payment, err := sqlStore.FetchPayment(ctx, hash) + require.NoError(t, err) + require.Zero( + t, payment.HTLCs[0].Route.Hops[0].TotalAmtMsat, + ) + + assertPaymentDataMatches(t, ctx, kvDB, sqlStore, paymentHash) + } + + t.Run("nil encrypted data", func(t *testing.T) { + runTest(t, "orphaned_total_nil", nil) + }) + t.Run("empty encrypted data", func(t *testing.T) { + runTest(t, "orphaned_total_empty", []byte{}) + }) +} + +// TestMigrateBlindingPointWithoutEncryptedData tests that migration reports a +// malformed blinded hop with enough context to locate the affected attempt. +func TestMigrateBlindingPointWithoutEncryptedData(t *testing.T) { + t.Parallel() + + ctx := context.Background() + kvDB := setupTestKVDB(t) + + var paymentHash [32]byte + copy(paymentHash[:], []byte("blinding_point_without_data")) + var attemptHash lntypes.Hash + copy(attemptHash[:], []byte("individual_amp_htlc_hash")) + + err := kvdb.Update(kvDB, func(tx kvdb.RwTx) error { + paymentsBucket, err := tx.CreateTopLevelBucket( + paymentsRootBucket, + ) + if err != nil { + return err + } + + indexBucket, err := tx.CreateTopLevelBucket( + paymentsIndexBucket, + ) + if err != nil { + return err + } + + return createTestPayment( + t, paymentsBucket, indexBucket, paymentTestConfig{ + hash: paymentHash, + attemptHash: &attemptHash, + seqNum: 1, + value: 120000, + creationTime: time.Unix(1, 0), + paymentRequest: "blinding-point-without-data", + attemptID: 1, + numHops: 1, + baseChannelID: 400000, + baseTimeLock: 800000, + hopConfigurator: func(hop *Hop, _ int, _ bool) { + blindingKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + hop.BlindingPoint = blindingKey.PubKey() + }, + }, + ) + }, func() {}) + require.NoError(t, err) + + sqlStore := setupTestSQLDB(t) + err = runPaymentsMigration(ctx, kvDB, sqlStore) + require.ErrorContains(t, err, "blinding point requires encrypted "+ + "recipient data") + require.ErrorContains(t, err, "attempt_index=1, hop=0") + require.ErrorContains(t, err, fmt.Sprintf( + "payment_hash=%x", paymentHash[:8], + )) + require.NotContains(t, err.Error(), fmt.Sprintf( + "payment_hash=%x", attemptHash[:8], + )) +} + // TestMigratePaymentWithMetadata tests migration of a payment with hop // metadata. func TestMigratePaymentWithMetadata(t *testing.T) { @@ -1934,6 +2075,7 @@ func assertPaymentDataMatches(t *testing.T, ctx context.Context, // features. type paymentTestConfig struct { hash [32]byte + attemptHash *lntypes.Hash seqNum uint64 value lnwire.MilliSatoshi creationTime time.Time @@ -2128,6 +2270,11 @@ func createTestPayment(t *testing.T, paymentsBucket, indexBucket kvdb.RwBucket, } // Create and serialize attempt info. + attemptHash := (*lntypes.Hash)(&cfg.hash) + if cfg.attemptHash != nil { + attemptHash = cfg.attemptHash + } + attemptInfo := &HTLCAttemptInfo{ AttemptID: cfg.attemptID, sessionKey: sessionKeyBytes, @@ -2139,7 +2286,7 @@ func createTestPayment(t *testing.T, paymentsBucket, indexBucket kvdb.RwBucket, FirstHopWireCustomRecords: cfg.attemptCustomRecs, }, AttemptTime: cfg.creationTime.Add(time.Minute), - Hash: (*lntypes.Hash)(&cfg.hash), + Hash: attemptHash, } if err = writeHTLCAttempt(