loopdb, staticaddr: harden SQL txid loading

Use strict txid parsing when reading swap updates and static address
loop-ins from SQL rows. Empty swap update txids still mean "absent",
but any non-empty truncated value now fails loudly.

Add store-level tests that inject truncated txids through the query
layer and assert that the fetch paths reject them.
This commit is contained in:
Boris Nagaev 2026-04-04 01:22:29 -05:00
parent 99d87422de
commit 7afb925d25
No known key found for this signature in database
4 changed files with 221 additions and 7 deletions

View file

@ -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
}
}

View file

@ -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()

View file

@ -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(

View file

@ -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)
}