From f25b5e9626a4a46288e365b72689ef600a11850f Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Mon, 7 Aug 2023 11:04:32 +0200 Subject: [PATCH] loopdb: fix faulty timestamps on startup This commit fixes faulty timestamps caused by using unix milliseconds as unix seconds on startup. This commit also adds a test for the lightning-terminal issue that first reported the bug. --- loopdb/postgres.go | 25 ++++-- loopdb/sql_test.go | 72 ++++++++++++++++ loopdb/sqlite.go | 183 ++++++++++++++++++++++++++++++++++++++-- loopdb/test_postgres.go | 4 + loopdb/test_sqlite.go | 4 + 5 files changed, 276 insertions(+), 12 deletions(-) diff --git a/loopdb/postgres.go b/loopdb/postgres.go index 590081f..7359e63 100644 --- a/loopdb/postgres.go +++ b/loopdb/postgres.go @@ -1,6 +1,7 @@ package loopdb import ( + "context" "database/sql" "fmt" "testing" @@ -104,13 +105,25 @@ func NewPostgresStore(cfg *PostgresConfig, queries := sqlc.New(rawDb) + baseDB := &BaseDB{ + DB: rawDb, + Queries: queries, + network: network, + } + + // Fix faulty timestamps in the database. + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + err = baseDB.FixFaultyTimestamps(ctx, parsePostgresTimeStamp) + if err != nil { + log.Errorf("Failed to fix faulty timestamps: %v", err) + return nil, err + } + return &PostgresStore{ - cfg: cfg, - BaseDB: &BaseDB{ - DB: rawDb, - Queries: queries, - network: network, - }, + cfg: cfg, + BaseDB: baseDB, }, nil } diff --git a/loopdb/sql_test.go b/loopdb/sql_test.go index 0aab5db..1ac3410 100644 --- a/loopdb/sql_test.go +++ b/loopdb/sql_test.go @@ -318,6 +318,78 @@ func TestSqliteTypeConversion(t *testing.T) { } +// TestIssue615 tests that on faulty timestamps, the database will be fixed. +// Reference: https://github.com/lightninglabs/lightning-terminal/issues/615 +func TestIssue615(t *testing.T) { + ctxb := context.Background() + + // Create a new sqlite store for testing. + sqlDB := NewTestDB(t) + + // Create a faulty loopout swap. + destAddr := test.GetDestAddr(t, 0) + faultyTime, err := parseSqliteTimeStamp("55563-06-27 02:09:24 +0000 UTC") + require.NoError(t, err) + + unrestrictedSwap := LoopOutContract{ + SwapContract: SwapContract{ + AmountRequested: 100, + Preimage: testPreimage, + CltvExpiry: 144, + HtlcKeys: HtlcKeys{ + SenderScriptKey: senderKey, + ReceiverScriptKey: receiverKey, + SenderInternalPubKey: senderInternalKey, + ReceiverInternalPubKey: receiverInternalKey, + ClientScriptKeyLocator: keychain.KeyLocator{ + Family: 1, + Index: 2, + }, + }, + MaxMinerFee: 10, + MaxSwapFee: 20, + + InitiationHeight: 99, + + InitiationTime: time.Now(), + ProtocolVersion: ProtocolVersionMuSig2, + }, + MaxPrepayRoutingFee: 40, + PrepayInvoice: "prepayinvoice", + DestAddr: destAddr, + SwapInvoice: "swapinvoice", + MaxSwapRoutingFee: 30, + SweepConfTarget: 2, + HtlcConfirmations: 2, + SwapPublicationDeadline: faultyTime, + } + + err = sqlDB.CreateLoopOut(ctxb, testPreimage.Hash(), &unrestrictedSwap) + require.NoError(t, err) + + // This should fail because of the faulty timestamp. + _, err = sqlDB.GetLoopOutSwaps(ctxb) + + // If we're using sqlite, we expect an error. + if testDBType == "sqlite" { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + parseFunc := parseSqliteTimeStamp + if testDBType == "postgres" { + parseFunc = parsePostgresTimeStamp + } + + // Fix the faulty timestamp. + err = sqlDB.FixFaultyTimestamps(ctxb, parseFunc) + require.NoError(t, err) + + _, err = sqlDB.GetLoopOutSwaps(ctxb) + require.NoError(t, err) +} + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" func randomString(length int) string { diff --git a/loopdb/sqlite.go b/loopdb/sqlite.go index 81d269e..9add23b 100644 --- a/loopdb/sqlite.go +++ b/loopdb/sqlite.go @@ -6,7 +6,10 @@ import ( "fmt" "net/url" "path/filepath" + "strconv" + "strings" "testing" + "time" "github.com/btcsuite/btcd/chaincfg" sqlite_migrate "github.com/golang-migrate/migrate/v4/database/sqlite" @@ -109,13 +112,25 @@ func NewSqliteStore(cfg *SqliteConfig, network *chaincfg.Params) (*SqliteSwapSto queries := sqlc.New(db) + baseDB := &BaseDB{ + DB: db, + Queries: queries, + network: network, + } + + // Fix faulty timestamps in the database. + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + err = baseDB.FixFaultyTimestamps(ctx, parseSqliteTimeStamp) + if err != nil { + log.Errorf("Failed to fix faulty timestamps: %v", err) + return nil, err + } + return &SqliteSwapStore{ - cfg: cfg, - BaseDB: &BaseDB{ - DB: db, - Queries: queries, - network: network, - }, + cfg: cfg, + BaseDB: baseDB, }, nil } @@ -127,6 +142,7 @@ func NewTestSqliteDB(t *testing.T) *SqliteSwapStore { t.Logf("Creating new SQLite DB for testing") dbFileName := filepath.Join(t.TempDir(), "tmp.db") + sqlDB, err := NewSqliteStore(&SqliteConfig{ DatabaseFileName: dbFileName, SkipMigrations: false, @@ -191,6 +207,79 @@ func (db *BaseDB) ExecTx(ctx context.Context, txOptions TxOptions, return nil } +// FixFaultyTimestamps fixes faulty timestamps in the database, caused +// by using milliseconds instead of seconds as the publication deadline. +func (b *BaseDB) FixFaultyTimestamps(ctx context.Context, + parseTimeFunc func(string) (time.Time, error)) error { + + // Manually fetch all the loop out swaps. + rows, err := b.DB.QueryContext( + ctx, "SELECT swap_hash, publication_deadline FROM loopout_swaps", + ) + if err != nil { + return err + } + + // Parse the rows into a struct. We need to do this manually because + // the sqlite driver will fail on faulty timestamps. + type LoopOutRow struct { + Hash []byte `json:"swap_hash"` + PublicationDeadline string `json:"publication_deadline"` + } + + var loopOutSwaps []LoopOutRow + + for rows.Next() { + var swap LoopOutRow + err := rows.Scan( + &swap.Hash, &swap.PublicationDeadline, + ) + if err != nil { + return err + } + + loopOutSwaps = append(loopOutSwaps, swap) + } + + tx, err := b.BeginTx(ctx, &SqliteTxOptions{}) + if err != nil { + return err + } + defer tx.Rollback() //nolint: errcheck + + for _, swap := range loopOutSwaps { + faultyTime, err := parseTimeFunc(swap.PublicationDeadline) + if err != nil { + return err + } + + // Skip if the time is not faulty. + if !isMilisecondsTime(faultyTime.Unix()) { + continue + } + + // Update the faulty time to a valid time. + secs := faultyTime.Unix() / 1000 + correctTime := time.Unix(secs, 0) + _, err = tx.ExecContext( + ctx, ` + UPDATE + loopout_swaps + SET + publication_deadline = $1 + WHERE + swap_hash = $2; + `, + correctTime, swap.Hash, + ) + if err != nil { + return err + } + } + + return tx.Commit() +} + // TxOptions represents a set of options one can use to control what type of // database transaction is created. Transaction can wither be read or write. type TxOptions interface { @@ -219,3 +308,85 @@ func NewSqlReadOpts() *SqliteTxOptions { func (r *SqliteTxOptions) ReadOnly() bool { return r.readOnly } + +// parseSqliteTimeStamp parses a timestamp string in the format of +// "YYYY-MM-DD HH:MM:SS +0000 UTC" and returns a time.Time value. +// NOTE: we can't use time.Parse() because it doesn't support having years +// with more than 4 digits. +func parseSqliteTimeStamp(dateTimeStr string) (time.Time, error) { + // Split the date and time parts. + parts := strings.Fields(strings.TrimSpace(dateTimeStr)) + datePart, timePart := parts[0], parts[1] + + return parseTimeParts(datePart, timePart) +} + +// parseSqliteTimeStamp parses a timestamp string in the format of +// "YYYY-MM-DDTHH:MM:SSZ" and returns a time.Time value. +// NOTE: we can't use time.Parse() because it doesn't support having years +// with more than 4 digits. +func parsePostgresTimeStamp(dateTimeStr string) (time.Time, error) { + // Split the date and time parts. + parts := strings.Split(dateTimeStr, "T") + datePart, timePart := parts[0], strings.TrimSuffix(parts[1], "Z") + + return parseTimeParts(datePart, timePart) +} + +// parseTimeParts takes a datePart string in the format of "YYYY-MM-DD" and +// a timePart string in the format of "HH:MM:SS" and returns a time.Time value. +func parseTimeParts(datePart, timePart string) (time.Time, error) { + // Parse the date. + dateParts := strings.Split(datePart, "-") + + year, err := strconv.Atoi(dateParts[0]) + if err != nil { + return time.Time{}, err + } + + month, err := strconv.Atoi(dateParts[1]) + if err != nil { + return time.Time{}, err + } + + day, err := strconv.Atoi(dateParts[2]) + if err != nil { + return time.Time{}, err + } + + // Parse the time. + timeParts := strings.Split(timePart, ":") + + hour, err := strconv.Atoi(timeParts[0]) + if err != nil { + return time.Time{}, err + } + + minute, err := strconv.Atoi(timeParts[1]) + if err != nil { + return time.Time{}, err + } + + second, err := strconv.Atoi(timeParts[2]) + if err != nil { + return time.Time{}, err + } + + // Construct a time.Time value. + return time.Date( + year, time.Month(month), day, hour, minute, second, 0, time.UTC, + ), nil +} + +// isMilisecondsTime returns true if the unix timestamp is likely in +// milliseconds. +func isMilisecondsTime(unixTimestamp int64) bool { + length := len(fmt.Sprintf("%d", unixTimestamp)) + if length >= 13 { + // Likely a millisecond timestamp + return true + } else { + // Likely a second timestamp + return false + } +} diff --git a/loopdb/test_postgres.go b/loopdb/test_postgres.go index 7579fb2..fafc9c1 100644 --- a/loopdb/test_postgres.go +++ b/loopdb/test_postgres.go @@ -7,6 +7,10 @@ import ( "testing" ) +var ( + testDBType = "postgres" +) + // NewTestDB is a helper function that creates a Postgres database for testing. func NewTestDB(t *testing.T) *PostgresStore { return NewTestPostgresDB(t) diff --git a/loopdb/test_sqlite.go b/loopdb/test_sqlite.go index 68c220b..e60547c 100644 --- a/loopdb/test_sqlite.go +++ b/loopdb/test_sqlite.go @@ -7,6 +7,10 @@ import ( "testing" ) +var ( + testDBType = "sqlite" +) + // NewTestDB is a helper function that creates an SQLite database for testing. func NewTestDB(t *testing.T) *SqliteSwapStore { return NewTestSqliteDB(t)