payments/db: normalize LegacyPayload flag in migration comparison
What changed, and why it matters
This commit fixes a test-only comparison helper used when validating that old payment data can be moved from LND's older key-value database to the newer SQL database. The 'LegacyPayload' flag only mattered to the old database format and is not stored in SQL. The change makes the comparison ignore that flag so migrations with legacy-format hops don't falsely fail validation. It is not a runtime security fix and does not change how user funds or payment data are handled in production.
No security action required. Treat as a normal correctness/test-quality patch. If reviewing, confirm the new test passes and that normalizePaymentForCompare is only used in migration validation, not production code paths.
Security signals we found
No security-relevant signal: change is in migration validation/test helper only
No memory safety, cryptographic, or authorization changes
No input parsing or network-facing code modified
Evidence from the diff
In payments/db/migration1/migration_validation.go, normalizePaymentForCompare now sets LegacyPayload=false on every hop before deep-equality checks between KV-backed and SQL-backed MPPayment structures. The SQL store persists hop fields natively and never stores LegacyPayload, so without normalization a KV payment with LegacyPayload=true would not compare equal to its SQL counterpart even though the migration is correct. A new unit test, TestMigrationLegacyPayloadNormalized, constructs a KV payment with LegacyPayload=true, runs the migration, and asserts comparePaymentData succeeds. The change is confined to migration validation/test code and does not alter production serialization, routing, or database behavior.
Changed components
payments/db/migration1/migration_validation.gopayments/db/migration1/sql_migration_test.goInspect captured patch +181 / −0
diff --git a/payments/db/migration1/migration_validation.go b/payments/db/migration1/migration_validation.go
index b2192e9..fe43cf2 100644
--- a/payments/db/migration1/migration_validation.go
+++ b/payments/db/migration1/migration_validation.go
@@ -320,6 +320,15 @@ func normalizePaymentForCompare(payment *MPPayment) {
htlc.Route.Hops[j].CustomRecords =
record.CustomSet{}
}
+
+ // 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
+ // TLV). The SQL store does not serialize hop data at
+ // 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
}
}
}
diff --git a/payments/db/migration1/sql_migration_test.go b/payments/db/migration1/sql_migration_test.go
index f1e96f0..f19dd91 100644
--- a/payments/db/migration1/sql_migration_test.go
+++ b/payments/db/migration1/sql_migration_test.go
@@ -156,6 +156,178 @@ func TestMigrationDataIntegrity(t *testing.T) {
}
}
+// TestMigrationLegacyPayloadNormalized verifies that a payment whose HTLC
+// hops carry LegacyPayload=true in the KV store is migrated and compared
+// correctly. LegacyPayload was a hint used by the KV store to choose between
+// the legacy and TLV hop-payload serialization formats. The SQL store does not
+// serialize hop data at all — each field is stored natively in its own
+// column — so the flag is never persisted there. normalizePaymentForCompare
+// clears it on both sides before the equality check, so the comparison must
+// succeed even when the KV source has LegacyPayload=true.
+func TestMigrationLegacyPayloadNormalized(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ kvDB := setupTestKVDB(t)
+
+ hash := createTestPaymentHash(t, 0)
+
+ 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
+ }
+
+ paymentBucket, err := paymentsBucket.CreateBucketIfNotExists(
+ hash[:],
+ )
+ if err != nil {
+ return err
+ }
+
+ var paymentID lntypes.Hash
+ copy(paymentID[:], hash[:])
+
+ creationInfo := &PaymentCreationInfo{
+ PaymentIdentifier: paymentID,
+ Value: lnwire.MilliSatoshi(1000000),
+ CreationTime: time.Now().Add(-24 * time.Hour),
+ PaymentRequest: []byte("lnbc1test"),
+ }
+
+ var b bytes.Buffer
+ if err = serializePaymentCreationInfo(
+ &b, creationInfo,
+ ); err != nil {
+ return err
+ }
+ if err = paymentBucket.Put(
+ paymentCreationInfoKey, b.Bytes(),
+ ); err != nil {
+ return err
+ }
+
+ seqBytes := make([]byte, 8)
+ byteOrder.PutUint64(seqBytes, 0)
+ if err = paymentBucket.Put(
+ paymentSequenceKey, seqBytes,
+ ); err != nil {
+ return err
+ }
+
+ htlcBucket, err := paymentBucket.CreateBucketIfNotExists(
+ paymentHtlcsBucket,
+ )
+ if err != nil {
+ return err
+ }
+
+ hop1Key, err := btcec.NewPrivateKey()
+ if err != nil {
+ return err
+ }
+
+ hop2Key, err := btcec.NewPrivateKey()
+ if err != nil {
+ return err
+ }
+
+ sessionKey, err := btcec.NewPrivateKey()
+ if err != nil {
+ return err
+ }
+
+ var sourcePubKey Vertex
+ copy(sourcePubKey[:], sessionKey.PubKey().SerializeCompressed())
+
+ var sessionKeyBytes [32]byte
+ copy(sessionKeyBytes[:], sessionKey.Serialize())
+
+ const attemptID = uint64(1)
+ attemptInfo := &HTLCAttemptInfo{
+ AttemptID: attemptID,
+ sessionKey: sessionKeyBytes,
+ Route: Route{
+ TotalTimeLock: 500000,
+ TotalAmount: lnwire.MilliSatoshi(1000000),
+ SourcePubKey: sourcePubKey,
+ Hops: []*Hop{
+ {
+ PubKeyBytes: NewVertex(
+ hop1Key.PubKey(),
+ ),
+ ChannelID: 123456,
+ OutgoingTimeLock: 499500,
+ AmtToForward: 900000,
+ LegacyPayload: true,
+ },
+ {
+ PubKeyBytes: NewVertex(
+ hop2Key.PubKey(),
+ ),
+ ChannelID: 789012,
+ OutgoingTimeLock: 499000,
+ AmtToForward: 800000,
+ LegacyPayload: true,
+ },
+ },
+ },
+ AttemptTime: time.Now().Add(-2 * time.Hour),
+ Hash: &paymentID,
+ }
+
+ if err = writeHTLCAttempt(
+ htlcBucket, attemptID, attemptInfo,
+ ); err != nil {
+ return err
+ }
+
+ settleInfo := &HTLCSettleInfo{
+ Preimage: lntypes.Preimage(paymentID),
+ SettleTime: time.Now().Add(-1 * time.Hour),
+ }
+ if err = writeHTLCSettle(
+ htlcBucket, attemptID, settleInfo,
+ ); err != nil {
+ return err
+ }
+
+ return createIndexEntry(indexBucket, seqBytes, hash)
+ }, func() {})
+ require.NoError(t, err)
+
+ // Fetch the payment from KV and verify LegacyPayload is true so we
+ // know the test data is correct before migration.
+ var kvPayment *MPPayment
+ err = kvdb.View(kvDB, func(tx kvdb.RTx) error {
+ bucket := tx.ReadBucket(paymentsRootBucket).
+ NestedReadBucket(hash[:])
+ var err error
+ kvPayment, err = fetchPayment(bucket)
+ return err
+ }, func() {})
+ require.NoError(t, err)
+ require.True(t, kvPayment.HTLCs[0].Route.Hops[0].LegacyPayload)
+ require.True(t, kvPayment.HTLCs[0].Route.Hops[1].LegacyPayload)
+
+ sqlStore := setupTestSQLDB(t)
+ require.NoError(t, runPaymentsMigration(ctx, kvDB, sqlStore))
+
+ // comparePaymentData normalizes both sides (clearing LegacyPayload)
+ // before the equality check, so this must pass.
+ comparePaymentData(t, ctx, sqlStore, kvPayment)
+}
+
// TestMigrationWithDuplicates tests migration of duplicate payments into
// the payment_duplicates table.
func TestMigrationWithDuplicates(t *testing.T) {
Why this scored 18/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.