From d3eb3cd3fc80c82653ac3ba8837402b2f7b332f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Tigerstr=C3=B6m?= Date: Thu, 7 Aug 2025 15:04:56 +0200 Subject: [PATCH 1/4] session: sort MacaroonRecipe.caveats in migration In the kvdb to sql migration, if there have been caveats set for the MacaroonRecipe, the order of the postgres db caveats will in very rare cases differ from the kv store caveats. Therefore, we sort both the kv and sql caveats by their ID, so that we can compare them in a deterministic way. --- session/sql_migration.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/session/sql_migration.go b/session/sql_migration.go index 428cc0fce..0f79cd9da 100644 --- a/session/sql_migration.go +++ b/session/sql_migration.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "reflect" + "sort" "time" "github.com/davecgh/go-spew/spew" @@ -380,11 +381,18 @@ func overrideSessionTimeZone(session *Session) { // as nil in the bbolt store. Therefore, we also override the permissions // or caveats to nil for the migrated session in that scenario, so that the // deep equals check does not fail in this scenario either. +// +// Additionally, we sort the caveats of both the kv and sql sessions by +// their ID, so that they are always comparable in a deterministic way with deep +// equals. func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) { if kvSession.MacaroonRecipe != nil { kvPerms := kvSession.MacaroonRecipe.Permissions kvCaveats := kvSession.MacaroonRecipe.Caveats + // If the kvSession has a MacaroonRecipe with nil set for any + // of the fields, we need to override the migratedSession + // MacaroonRecipe to match that. if kvPerms == nil && kvCaveats == nil { migratedSession.MacaroonRecipe = &MacaroonRecipe{} } else if kvPerms == nil { @@ -392,5 +400,26 @@ func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) { } else if kvCaveats == nil { migratedSession.MacaroonRecipe.Caveats = nil } + + sqlCaveats := migratedSession.MacaroonRecipe.Caveats + + // If there have been caveats set for the MacaroonRecipe, + // the order of the postgres db caveats will in very rare cases + // differ from the kv store caveats. Therefore, we sort + // both the kv and sql caveats by their ID, so that we can + // compare them in a deterministic way. + if kvCaveats != nil { + sort.Slice(kvCaveats, func(i, j int) bool { + return bytes.Compare( + kvCaveats[i].Id, kvCaveats[j].Id, + ) < 0 + }) + + sort.Slice(sqlCaveats, func(i, j int) bool { + return bytes.Compare( + sqlCaveats[i].Id, sqlCaveats[j].Id, + ) < 0 + }) + } } } From 1af12f4fb9f8c7cbada060f9dfe203a901757db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Tigerstr=C3=B6m?= Date: Fri, 15 Aug 2025 17:18:00 +0200 Subject: [PATCH 2/4] session: sort MacaroonRecipe.Permissions in migration Similar to the previous commit, we also sort the `MacaroonRecipe.Permissions` slice to ensure it can be compared in a deterministic manner during migrations. --- session/sql_migration.go | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/session/sql_migration.go b/session/sql_migration.go index 0f79cd9da..0288a03cb 100644 --- a/session/sql_migration.go +++ b/session/sql_migration.go @@ -382,9 +382,9 @@ func overrideSessionTimeZone(session *Session) { // or caveats to nil for the migrated session in that scenario, so that the // deep equals check does not fail in this scenario either. // -// Additionally, we sort the caveats of both the kv and sql sessions by -// their ID, so that they are always comparable in a deterministic way with deep -// equals. +// Additionally, we sort the caveats & permissions of both the kv and sql +// sessions by their ID, so that they are always comparable in a deterministic +// way with deep equals. func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) { if kvSession.MacaroonRecipe != nil { kvPerms := kvSession.MacaroonRecipe.Permissions @@ -402,6 +402,7 @@ func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) { } sqlCaveats := migratedSession.MacaroonRecipe.Caveats + sqlPerms := migratedSession.MacaroonRecipe.Permissions // If there have been caveats set for the MacaroonRecipe, // the order of the postgres db caveats will in very rare cases @@ -421,5 +422,28 @@ func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) { ) < 0 }) } + + // Similarly, we sort the macaroon permissions for both the kv + // and sql sessions, so that we can compare them in a + // deterministic way. + if kvPerms != nil { + sort.Slice(kvPerms, func(i, j int) bool { + if kvPerms[i].Entity == kvPerms[j].Entity { + return kvPerms[i].Action < + kvPerms[j].Action + } + + return kvPerms[i].Entity < kvPerms[j].Entity + }) + + sort.Slice(sqlPerms, func(i, j int) bool { + if sqlPerms[i].Entity == sqlPerms[j].Entity { + return sqlPerms[i].Action < + sqlPerms[j].Action + } + + return sqlPerms[i].Entity < sqlPerms[j].Entity + }) + } } } From 3b51680f2d942df75933f644c97cdec47afa4db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Tigerstr=C3=B6m?= Date: Fri, 15 Aug 2025 14:20:32 +0200 Subject: [PATCH 3/4] accounts: add deterministic map compare This commit adds a deterministic comparison for the maps in accounts during the KVDB to SQL migration. --- accounts/sql_migration.go | 118 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 4 deletions(-) diff --git a/accounts/sql_migration.go b/accounts/sql_migration.go index c36b51c6f..92f8af513 100644 --- a/accounts/sql_migration.go +++ b/accounts/sql_migration.go @@ -6,13 +6,16 @@ import ( "database/sql" "errors" "fmt" + "github.com/lightningnetwork/lnd/lntypes" "math" "reflect" + "sort" "time" "github.com/davecgh/go-spew/spew" "github.com/lightninglabs/lightning-terminal/db/sqlc" "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lnwire" "github.com/pmezard/go-difflib/difflib" ) @@ -23,6 +26,110 @@ var ( "original account") ) +// deterministicPayment is a variant of a single account PaymentEntry, which +// can be inserted into an array to be compared deterministically. +type deterministicPayment struct { + paymentHash lntypes.Hash + paymentEntry *PaymentEntry +} + +// deterministicAccount is a variant of the OffChainBalanceAccount struct +// without any struct methods, which represents the maps in the +// OffChainBalanceAccount as lists, so that they can be deterministically sorted +// for comparison during the kvdb to SQL migration. +type deterministicAccount struct { + // ID is the randomly generated account identifier. + ID AccountID + + // Type is the account type. + Type AccountType + + // InitialBalance stores the initial balance in millisatoshis and is + // never updated. + InitialBalance lnwire.MilliSatoshi + + // CurrentBalance is the currently available balance of the account + // in millisatoshis that is updated every time an invoice is paid. This + // value can be negative (for example if the fees for a payment are + // larger than the estimate made when checking the balance and the + // account is close to zero value). + CurrentBalance int64 + + // LastUpdate keeps track of the last time the balance of the account + // was updated. + LastUpdate time.Time + + // ExpirationDate is a specific date in the future after which the + // account is marked as expired. Can be set to zero for accounts that + // never expire. + ExpirationDate time.Time + + // Invoices is a list of all invoices that are associated with the + // account. + Invoices []lntypes.Hash + + // Payments is a list of all payments that are associated with the + // account and the last status we were aware of. + Payments []*deterministicPayment + + // Label is an optional label that can be set for the account. If it is + // not empty then it must be unique. + Label string +} + +// newDeterministicAccount creates a new deterministic account from the +// an OffChainBalanceAccount. +func newDeterministicAccount( + acct *OffChainBalanceAccount) *deterministicAccount { + + invoices := make([]lntypes.Hash, len(acct.Invoices)) + payments := make([]*deterministicPayment, len(acct.Payments)) + + // First let's populate the invoices and payments slices with the + // invoices and payments from the account. + i := 0 + for hash := range acct.Invoices { + invoices[i] = hash + i++ + } + + i = 0 + for hash, paymentEntry := range acct.Payments { + payments[i] = &deterministicPayment{ + paymentHash: hash, + paymentEntry: paymentEntry, + } + + i++ + } + + // Next, let's sort the invoices and payments slices by their hashes to + // ensure deterministic ordering. + sort.Slice(invoices, func(i, j int) bool { + return bytes.Compare( + invoices[i][:], invoices[j][:], + ) < 0 + }) + + sort.Slice(payments, func(i, j int) bool { + return bytes.Compare( + payments[i].paymentHash[:], payments[j].paymentHash[:], + ) < 0 + }) + + return &deterministicAccount{ + ID: acct.ID, + Type: acct.Type, + InitialBalance: acct.InitialBalance, + CurrentBalance: acct.CurrentBalance, + LastUpdate: acct.LastUpdate.UTC(), + Label: acct.Label, + Invoices: invoices, + Payments: payments, + ExpirationDate: acct.ExpirationDate.UTC(), + } +} + // MigrateAccountStoreToSQL runs the migration of all accounts and indices from // the KV database to the SQL database. The migration is done in a single // transaction to ensure that all accounts are migrated or none at all. @@ -79,13 +186,16 @@ func migrateAccountsToSQL(ctx context.Context, kvStore kvdb.Backend, overrideAccountTimeZone(kvAccount) overrideAccountTimeZone(migratedAccount) - if !reflect.DeepEqual(kvAccount, migratedAccount) { + dKvAccount := newDeterministicAccount(kvAccount) + dMigratedAccount := newDeterministicAccount(migratedAccount) + + if !reflect.DeepEqual(dKvAccount, dMigratedAccount) { diff := difflib.UnifiedDiff{ A: difflib.SplitLines( - spew.Sdump(kvAccount), + spew.Sdump(dKvAccount), ), B: difflib.SplitLines( - spew.Sdump(migratedAccount), + spew.Sdump(dMigratedAccount), ), FromFile: "Expected", FromDate: "", @@ -96,7 +206,7 @@ func migrateAccountsToSQL(ctx context.Context, kvStore kvdb.Backend, diffText, _ := difflib.GetUnifiedDiffString(diff) return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch, - kvAccount.ID, diffText) + dKvAccount.ID, diffText) } } From 0e3eba8f4c8f6a0528abf7ebdd7d29422bbd76f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Tigerstr=C3=B6m?= Date: Fri, 15 Aug 2025 14:21:03 +0200 Subject: [PATCH 4/4] session: add deterministic map compare This commit adds a deterministic comparison for the maps in sessions during the KVDB to SQL migration. --- session/sql_migration.go | 113 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/session/sql_migration.go b/session/sql_migration.go index 0288a03cb..c9d3ac7f6 100644 --- a/session/sql_migration.go +++ b/session/sql_migration.go @@ -10,9 +10,12 @@ import ( "sort" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/davecgh/go-spew/spew" + "github.com/lightninglabs/lightning-node-connect/mailbox" "github.com/lightninglabs/lightning-terminal/accounts" "github.com/lightninglabs/lightning-terminal/db/sqlc" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/sqldb" "github.com/pmezard/go-difflib/difflib" "go.etcd.io/bbolt" @@ -25,6 +28,105 @@ var ( "original session") ) +// featureConfigEntry is a variant of a single session feature config, which +// can be inserted into an array to be compared deterministically. +type featureConfigEntry struct { + featureName string + config []byte +} + +// deterministicSession is a variant of the Session struct without any struct +// methods, which represents the map in the Session as a list, so that it can be +// deterministically sorted for comparison during the kvdb to SQL migration. +type deterministicSession struct { + ID ID + Label string + State State + Type Type + Expiry time.Time + CreatedAt time.Time + RevokedAt time.Time + ServerAddr string + DevServer bool + MacaroonRootKey uint64 + MacaroonRecipe *MacaroonRecipe + PairingSecret [mailbox.NumPassphraseEntropyBytes]byte + LocalPrivateKey *btcec.PrivateKey + LocalPublicKey *btcec.PublicKey + RemotePublicKey *btcec.PublicKey + FeatureConfig []*featureConfigEntry + WithPrivacyMapper bool + PrivacyFlags PrivacyFlags + + // GroupID is the Session ID of the very first Session in the linked + // group of sessions. If this is the very first session in the group + // then this will be the same as ID. + GroupID ID + + // AccountID is an optional account that the session has been linked to. + AccountID fn.Option[accounts.AccountID] +} + +// newDeterministicSession creates a deterministicSession from a Session struct. +// This is used to compare the session in a deterministic way during the +// migration from the KV database to the SQL database. +func newDeterministicSession(sess *Session) *deterministicSession { + var featuresConfig []*featureConfigEntry + + // If a session has a feature config set, we'll convert it to an array + // so that we can sort it and compare it deterministically. + if sess.FeatureConfig != nil { + sessFC := *sess.FeatureConfig + featuresConfig = make([]*featureConfigEntry, len(sessFC)) + + i := 0 + for featureName, config := range sessFC { + featuresConfig[i] = &featureConfigEntry{ + featureName: featureName, + config: config, + } + + i++ + } + + // Sort the feature config entries by their feature name, and + // by their config bytes if the feature names are the same. + sort.Slice(featuresConfig, func(i, j int) bool { + iC := featuresConfig[i] + jC := featuresConfig[j] + + if iC.featureName == jC.featureName { + return bytes.Compare(iC.config, jC.config) < 0 + } + + return iC.featureName < jC.featureName + }) + } + + return &deterministicSession{ + ID: sess.ID, + Label: sess.Label, + State: sess.State, + Type: sess.Type, + Expiry: sess.Expiry, + CreatedAt: sess.CreatedAt, + RevokedAt: sess.RevokedAt, + ServerAddr: sess.ServerAddr, + DevServer: sess.DevServer, + MacaroonRootKey: sess.MacaroonRootKey, + PairingSecret: sess.PairingSecret, + LocalPrivateKey: sess.LocalPrivateKey, + LocalPublicKey: sess.LocalPublicKey, + RemotePublicKey: sess.RemotePublicKey, + GroupID: sess.GroupID, + AccountID: sess.AccountID, + PrivacyFlags: sess.PrivacyFlags, + MacaroonRecipe: sess.MacaroonRecipe, + WithPrivacyMapper: sess.WithPrivacyMapper, + FeatureConfig: featuresConfig, + } +} + // MigrateSessionStoreToSQL runs the migration of all sessions from the KV // database to the SQL database. The migration is done in a single transaction // to ensure that all sessions are migrated or none at all. @@ -149,13 +251,16 @@ func migrateSessionsToSQLAndValidate(ctx context.Context, overrideSessionTimeZone(migratedSession) overrideMacaroonRecipe(kvSession, migratedSession) - if !reflect.DeepEqual(kvSession, migratedSession) { + dKvSession := newDeterministicSession(kvSession) + dMigratedSession := newDeterministicSession(migratedSession) + + if !reflect.DeepEqual(dKvSession, dMigratedSession) { diff := difflib.UnifiedDiff{ A: difflib.SplitLines( - spew.Sdump(kvSession), + spew.Sdump(dKvSession), ), B: difflib.SplitLines( - spew.Sdump(migratedSession), + spew.Sdump(dMigratedSession), ), FromFile: "Expected", FromDate: "", @@ -166,7 +271,7 @@ func migrateSessionsToSQLAndValidate(ctx context.Context, diffText, _ := difflib.GetUnifiedDiffString(diff) return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch, - kvSession.ID, diffText) + dKvSession.ID, diffText) } }