Skip to content

Commit e18ae71

Browse files
committed
sweepbatcher: harden batch txid loading
Return errors from batch row conversion instead of silently accepting malformed batch txids while appending nil batches.
1 parent 7afb925 commit e18ae71

2 files changed

Lines changed: 163 additions & 8 deletions

File tree

sweepbatcher/store.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/btcsuite/btcd/wire"
1212
"github.com/lightninglabs/loop/loopdb"
1313
"github.com/lightninglabs/loop/loopdb/sqlc"
14+
"github.com/lightninglabs/loop/utils/chainhashutil"
1415
"github.com/lightningnetwork/lnd/lntypes"
1516
)
1617

@@ -91,15 +92,15 @@ func (s *SQLStore) FetchUnconfirmedSweepBatches(ctx context.Context) (
9192
}
9293

9394
for _, dbBatch := range dbBatches {
94-
batch := convertBatchRow(dbBatch)
95+
batch, err := convertBatchRow(dbBatch)
9596
if err != nil {
9697
return nil, err
9798
}
9899

99100
batches = append(batches, batch)
100101
}
101102

102-
return batches, err
103+
return batches, nil
103104
}
104105

105106
// InsertSweepBatch inserts a batch into the database, returning the id of the
@@ -198,7 +199,7 @@ func (s *SQLStore) GetParentBatch(ctx context.Context, outpoint wire.OutPoint) (
198199
return nil, err
199200
}
200201

201-
return convertBatchRow(batch), nil
202+
return convertBatchRow(batch)
202203
}
203204

204205
// UpsertSweep inserts a sweep into the database, or updates an existing sweep
@@ -258,17 +259,24 @@ type dbSweep struct {
258259
}
259260

260261
// convertBatchRow converts a batch row from db to a sweepbatcher.Batch struct.
261-
func convertBatchRow(row sqlc.SweepBatch) *dbBatch {
262+
func convertBatchRow(row sqlc.SweepBatch) (*dbBatch, error) {
262263
batch := dbBatch{
263264
ID: row.ID,
264265
Confirmed: row.Confirmed,
265266
}
266267

267-
if row.BatchTxID.Valid {
268-
err := chainhash.Decode(&batch.BatchTxid, row.BatchTxID.String)
268+
// Loop never writes empty batch txids, but tolerate them on read so a
269+
// malformed row does not prevent batcher recovery.
270+
if row.BatchTxID.Valid && row.BatchTxID.String != "" {
271+
hash, err := chainhashutil.NewHashFromStrExact(
272+
row.BatchTxID.String,
273+
)
269274
if err != nil {
270-
return nil
275+
return nil, fmt.Errorf("invalid batch txid %q: %w",
276+
row.BatchTxID.String, err)
271277
}
278+
279+
batch.BatchTxid = hash
272280
}
273281

274282
batch.BatchPkScript = row.BatchPkScript
@@ -283,7 +291,7 @@ func convertBatchRow(row sqlc.SweepBatch) *dbBatch {
283291

284292
batch.MaxTimeoutDistance = row.MaxTimeoutDistance
285293

286-
return &batch
294+
return &batch, nil
287295
}
288296

289297
// batchToInsertArgs converts a Batch struct to the arguments needed to insert

sweepbatcher/store_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package sweepbatcher
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"strings"
7+
"testing"
8+
9+
"github.com/btcsuite/btcd/chaincfg"
10+
"github.com/btcsuite/btcd/wire"
11+
"github.com/lightninglabs/loop/loopdb"
12+
"github.com/lightninglabs/loop/loopdb/sqlc"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestFetchUnconfirmedSweepBatchesRejectsInvalidBatchTxID verifies that
17+
// malformed persisted batch txids are rejected during batch loading.
18+
func TestFetchUnconfirmedSweepBatchesRejectsInvalidBatchTxID(t *testing.T) {
19+
t.Parallel()
20+
21+
tests := []struct {
22+
name string
23+
txid string
24+
errMsg string
25+
}{
26+
{
27+
name: "short",
28+
txid: "abcd",
29+
errMsg: "invalid batch txid",
30+
},
31+
{
32+
name: "non-hex",
33+
txid: strings.Repeat("z", 64),
34+
errMsg: "invalid batch txid",
35+
},
36+
}
37+
38+
for _, test := range tests {
39+
t.Run(test.name, func(t *testing.T) {
40+
t.Parallel()
41+
42+
ctxb := t.Context()
43+
testDb := loopdb.NewTestDB(t)
44+
defer testDb.Close()
45+
46+
store := NewSQLStore(
47+
loopdb.NewTypedStore[Querier](testDb),
48+
&chaincfg.RegressionNetParams,
49+
)
50+
51+
_, err := testDb.Queries.InsertBatch(
52+
ctxb, sqlc.InsertBatchParams{
53+
BatchTxID: sql.NullString{
54+
String: test.txid,
55+
Valid: true,
56+
},
57+
BatchPkScript: []byte{0x00},
58+
LastRbfHeight: sql.NullInt32{
59+
Int32: 1,
60+
Valid: true,
61+
},
62+
LastRbfSatPerKw: sql.NullInt32{
63+
Int32: 1000,
64+
Valid: true,
65+
},
66+
MaxTimeoutDistance: 1,
67+
},
68+
)
69+
require.NoError(t, err)
70+
71+
_, err = store.FetchUnconfirmedSweepBatches(ctxb)
72+
require.ErrorContains(t, err, test.errMsg)
73+
})
74+
}
75+
}
76+
77+
// TestFetchUnconfirmedSweepBatchesAllowsEmptyBatchTxID verifies that empty
78+
// persisted batch txids are tolerated for recovery robustness.
79+
func TestFetchUnconfirmedSweepBatchesAllowsEmptyBatchTxID(t *testing.T) {
80+
t.Parallel()
81+
82+
ctxb := t.Context()
83+
testDb := loopdb.NewTestDB(t)
84+
defer testDb.Close()
85+
86+
store := NewSQLStore(
87+
loopdb.NewTypedStore[Querier](testDb),
88+
&chaincfg.RegressionNetParams,
89+
)
90+
91+
_, err := testDb.Queries.InsertBatch(
92+
ctxb, sqlc.InsertBatchParams{
93+
BatchTxID: sql.NullString{
94+
String: "",
95+
Valid: true,
96+
},
97+
BatchPkScript: []byte{0x00},
98+
LastRbfHeight: sql.NullInt32{
99+
Int32: 1,
100+
Valid: true,
101+
},
102+
LastRbfSatPerKw: sql.NullInt32{
103+
Int32: 1000,
104+
Valid: true,
105+
},
106+
MaxTimeoutDistance: 1,
107+
},
108+
)
109+
require.NoError(t, err)
110+
111+
_, err = store.FetchUnconfirmedSweepBatches(ctxb)
112+
require.NoError(t, err)
113+
}
114+
115+
// parentBatchDB is a test double that overrides GetParentBatch while reusing
116+
// the rest of the SQL store interface from an embedded BaseDB.
117+
type parentBatchDB struct {
118+
BaseDB
119+
120+
batch sqlc.SweepBatch
121+
err error
122+
}
123+
124+
// GetParentBatch returns the preconfigured batch row for store tests.
125+
func (s parentBatchDB) GetParentBatch(ctx context.Context,
126+
outpoint string) (sqlc.SweepBatch, error) {
127+
128+
return s.batch, s.err
129+
}
130+
131+
// TestGetParentBatchRejectsInvalidBatchTxID verifies that malformed persisted
132+
// batch txids are rejected through the GetParentBatch path as well.
133+
func TestGetParentBatchRejectsInvalidBatchTxID(t *testing.T) {
134+
t.Parallel()
135+
136+
store := NewSQLStore(parentBatchDB{
137+
batch: sqlc.SweepBatch{
138+
BatchTxID: sql.NullString{
139+
String: "abcd",
140+
Valid: true,
141+
},
142+
},
143+
}, &chaincfg.RegressionNetParams)
144+
145+
_, err := store.GetParentBatch(t.Context(), wire.OutPoint{})
146+
require.ErrorContains(t, err, "invalid batch txid")
147+
}

0 commit comments

Comments
 (0)