diff --git a/loopdb/sql_store.go b/loopdb/sql_store.go index da83f49a..6679fd88 100644 --- a/loopdb/sql_store.go +++ b/loopdb/sql_store.go @@ -4,14 +4,15 @@ import ( "context" "database/sql" "errors" + "fmt" "strconv" "strings" "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/utils/chainhashutil" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/routing/route" @@ -754,12 +755,15 @@ func getSwapEvents(updates []sqlc.SwapUpdate) ([]*LoopEvent, error) { } if updates[i].HtlcTxhash != "" { - chainHash, err := chainhash.NewHashFromStr(updates[i].HtlcTxhash) + chainHash, err := chainhashutil.NewHashFromStrExact( + updates[i].HtlcTxhash, + ) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid htlc tx hash "+ + "%q: %w", updates[i].HtlcTxhash, err) } - events[i].HtlcTxHash = chainHash + events[i].HtlcTxHash = &chainHash } } diff --git a/loopdb/sql_test.go b/loopdb/sql_test.go index 8312a793..4369394e 100644 --- a/loopdb/sql_test.go +++ b/loopdb/sql_test.go @@ -535,6 +535,58 @@ func TestBatchUpdateCost(t *testing.T) { require.Equal(t, updateMap[hash2], swapsMap[hash2].State().Cost) } +// TestSqliteRejectsTruncatedHtlcTxHash verifies that a persisted short HTLC +// txid is rejected instead of being accepted as a padded hash. +func TestSqliteRejectsTruncatedHtlcTxHash(t *testing.T) { + store := NewTestDB(t) + + destAddr := test.GetDestAddr(t, 0) + pendingSwap := 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: testTime, + ProtocolVersion: ProtocolVersionMuSig2, + }, + PrepayInvoice: "prepayinvoice", + DestAddr: destAddr, + SwapInvoice: "swapinvoice", + SweepConfTarget: 2, + HtlcConfirmations: 2, + } + + ctxb := t.Context() + hash := pendingSwap.Preimage.Hash() + + err := store.CreateLoopOut(ctxb, hash, &pendingSwap) + require.NoError(t, err) + + err = store.Queries.InsertSwapUpdate(ctxb, sqlc.InsertSwapUpdateParams{ + SwapHash: hash[:], + UpdateTimestamp: testTime, + UpdateState: int32(StatePreimageRevealed), + HtlcTxhash: "abcd", + }) + require.NoError(t, err) + + _, err = store.FetchLoopOutSwap(ctxb, hash) + require.ErrorContains(t, err, "invalid htlc tx hash") +} + // TestMigrationTracker tests the migration tracker functionality. func TestMigrationTracker(t *testing.T) { ctxb := context.Background() diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 1b70bbc4..ab4da427 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "github.com/btcsuite/btcd/btcec/v2" @@ -15,6 +16,7 @@ import ( "github.com/lightninglabs/loop/loopdb/sqlc" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/utils/chainhashutil" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" @@ -498,13 +500,20 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } var htlcTimeoutSweepTxHash *chainhash.Hash - if swap.HtlcTimeoutSweepTxID.Valid { - htlcTimeoutSweepTxHash, err = chainhash.NewHashFromStr( + // Loop never writes empty timeout sweep txids, but tolerate them on read + // so a malformed row does not prevent swap recovery. + if swap.HtlcTimeoutSweepTxID.Valid && + swap.HtlcTimeoutSweepTxID.String != "" { + + hash, err := chainhashutil.NewHashFromStrExact( swap.HtlcTimeoutSweepTxID.String, ) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid htlc timeout sweep txid %q: %w", + swap.HtlcTimeoutSweepTxID.String, err) } + + htlcTimeoutSweepTxHash = &hash } depositOutpoints := strings.Split( diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 356049bc..68aef2a6 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -2,6 +2,7 @@ package loopin import ( "context" + "database/sql" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/loopdb/sqlc" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/clock" @@ -271,3 +273,150 @@ func TestCreateLoopIn(t *testing.T) { require.Equal(t, d2.Value, swap.Deposits[1].Value) require.Equal(t, deposit.LoopingIn, swap.Deposits[1].GetState()) } + +// TestGetLoopInByHashRejectsTruncatedTimeoutSweepTxID verifies that a +// persisted short timeout sweep txid is rejected during swap loading. +func TestGetLoopInByHashRejectsTruncatedTimeoutSweepTxID(t *testing.T) { + ctxb := t.Context() + testDb := loopdb.NewTestDB(t) + testClock := clock.NewTestClock(time.Now()) + defer testDb.Close() + + depositStore := deposit.NewSqlStore(testDb.BaseDB) + swapStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDb), testClock, + &chaincfg.RegressionNetParams, + ) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + + depositRecord := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + }, + Value: btcutil.Amount(100_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, + }, + } + + err = depositStore.CreateDeposit(ctxb, depositRecord) + require.NoError(t, err) + + depositRecord.SetState(deposit.LoopingIn) + err = depositStore.UpdateDeposit(ctxb, depositRecord) + require.NoError(t, err) + + _, clientPubKey := test.CreateKey(1) + _, serverPubKey := test.CreateKey(2) + addr, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{0x1, 0x2, 0x3, 0x4} + loopIn := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4}, + DepositOutpoints: []string{ + depositRecord.OutPoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + loopIn.SetState(SignHtlcTx) + + err = swapStore.CreateLoopIn(ctxb, &loopIn) + require.NoError(t, err) + + err = testDb.Queries.UpdateStaticAddressLoopIn( + ctxb, sqlc.UpdateStaticAddressLoopInParams{ + SwapHash: swapHash[:], + HtlcTimeoutSweepTxID: sql.NullString{ + String: "abcd", + Valid: true, + }, + }, + ) + require.NoError(t, err) + + _, err = swapStore.GetLoopInByHash(ctxb, swapHash) + require.ErrorContains(t, err, "invalid htlc timeout sweep txid") +} + +// TestGetLoopInByHashAllowsEmptyTimeoutSweepTxID verifies that empty persisted +// timeout sweep txids are tolerated for recovery robustness. +func TestGetLoopInByHashAllowsEmptyTimeoutSweepTxID(t *testing.T) { + ctxb := t.Context() + testDb := loopdb.NewTestDB(t) + testClock := clock.NewTestClock(time.Now()) + defer testDb.Close() + + depositStore := deposit.NewSqlStore(testDb.BaseDB) + swapStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDb), testClock, + &chaincfg.RegressionNetParams, + ) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + + depositRecord := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + }, + Value: btcutil.Amount(100_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, + }, + } + + err = depositStore.CreateDeposit(ctxb, depositRecord) + require.NoError(t, err) + + depositRecord.SetState(deposit.LoopingIn) + err = depositStore.UpdateDeposit(ctxb, depositRecord) + require.NoError(t, err) + + _, clientPubKey := test.CreateKey(1) + _, serverPubKey := test.CreateKey(2) + addr, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{0x1, 0x2, 0x3, 0x4} + loopIn := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4}, + DepositOutpoints: []string{ + depositRecord.OutPoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + loopIn.SetState(SignHtlcTx) + + err = swapStore.CreateLoopIn(ctxb, &loopIn) + require.NoError(t, err) + + err = testDb.Queries.UpdateStaticAddressLoopIn( + ctxb, sqlc.UpdateStaticAddressLoopInParams{ + SwapHash: swapHash[:], + HtlcTimeoutSweepTxID: sql.NullString{ + String: "", + Valid: true, + }, + }, + ) + require.NoError(t, err) + + swap, err := swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.Nil(t, swap.HtlcTimeoutSweepTxHash) +}