paymentsdb: normalize orphaned blinded total
What changed, and why it matters
This commit fixes a database migration bug in LND (Lightning Network Daemon) that could prevent the node from starting after upgrading. Some old payment routes stored a 'blinded total amount' without the matching encrypted recipient data. The previous migration incorrectly treated that total as proof of a real blinded hop and tried to insert a row with missing required data, causing a SQL constraint failure on startup. The fix uses the presence of encrypted recipient data as the real signal for a blinded hop, silently drops the harmless 'orphaned total' case, and reports a clear error for the genuinely malformed case of a blinding point without encrypted data. It also adds regression tests and adjusts validation so the migrated SQL data matches the original KV data.
Apply the patch before any deployment that migrates KV payment databases to SQL. Operators who have already hit the startup failure should upgrade to a release containing this commit and restart; no manual database repair should be needed because the migration will now skip the orphaned-total-only rows. Review any logs mentioning 'blinding point requires encrypted recipient data' to identify genuinely malformed payment records.
Security signals we found
Fixes a startup-denial condition caused by a SQL constraint violation during migration
Changes discriminator for sensitive blinded-hop rows from a monetary total to encrypted recipient data
Adds explicit malformed-data rejection with payment/attempt/hop context instead of an opaque SQL error
Adds regression tests covering both nil and empty encrypted-data orphaned-total variants
Adjusts migration validation to account for normalized totals so KV vs SQL comparison passes
Evidence from the diff
In payments/db/migration1, the SQL migration of KV payment route hops is changed so that a blinded child row is created only when hop.EncryptedData is non-empty. Previously, the condition also included hop.BlindingPoint != nil || hop.TotalAmtMsat != 0, which caused an orphaned TotalAmtMsat (from SendToRouteV2 routes) to be treated as a blinded hop and bound nil/empty to the required encrypted_data column, producing a SQL NOT NULL/constraint error that blocked LND startup. The patch normalizes the orphaned-total-only case by ignoring it, adds a targeted error for blinding-point-without-encrypted-data, threads parentPaymentHash into migrateRouteHop for diagnostics, updates normalizePaymentForCompare to mirror the same normalization, and adds regression tests for both nil and empty encrypted data with a total, plus a test for the malformed blinding-point-only case.
Changed components
payments/db/migration1/sql_migration.gopayments/db/migration1/migration_validation.gopayments/db/migration1/sql_migration_test.goInspect captured patch +202 / −11
diff --git a/payments/db/migration1/migration_validation.go b/payments/db/migration1/migration_validation.go
index c81303e..7b239d2 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 a353766..9e187b8 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 05b4d12..295f230 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(
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.