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(