Skip to content

Commit 44c2d04

Browse files
committed
staticaddr: clear stale HTLC confs on re-register
- reset htlcConfirmed when the HTLC conf subscription errors and we re-register - add regression test ensuring re-registers after a conf error require a fresh confirmation instead of sweeping on a stale one
1 parent ac53fbd commit 44c2d04

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

staticaddr/loopin/actions.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
575575
f.Errorf("htlc tx conf chan error, re-registering: "+
576576
"%v", err)
577577

578+
// A previous confirmation may no longer be valid if the
579+
// subscription failed, so reset and wait for a fresh one.
580+
htlcConfirmed = false
581+
578582
// Re-register for htlc confirmation.
579583
htlcConfChan, htlcErrConfChan, err = registerHtlcConf()
580584
if err != nil {

staticaddr/loopin/actions_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import (
77
"time"
88

99
"github.com/btcsuite/btcd/btcec/v2"
10+
"github.com/btcsuite/btcd/wire"
1011
"github.com/lightninglabs/lndclient"
1112
"github.com/lightninglabs/loop/fsm"
1213
"github.com/lightninglabs/loop/staticaddr/address"
1314
"github.com/lightninglabs/loop/staticaddr/deposit"
1415
"github.com/lightninglabs/loop/staticaddr/script"
1516
"github.com/lightninglabs/loop/staticaddr/version"
1617
"github.com/lightninglabs/loop/test"
18+
"github.com/lightningnetwork/lnd/chainntnfs"
1719
"github.com/lightningnetwork/lnd/invoices"
1820
"github.com/lightningnetwork/lnd/lntypes"
1921
"github.com/stretchr/testify/require"
@@ -204,6 +206,122 @@ func TestMonitorInvoiceAndHtlcTxInvoiceErr(t *testing.T) {
204206
}
205207
}
206208

209+
// TestMonitorInvoiceAndHtlcTxStaleConfirmation verifies we clear a stale
210+
// htlcConfirmed flag across a re-registration after a confirmation stream
211+
// error. Without the fix we would think the HTLC is confirmed and sweep on
212+
// timeout even though the confirmation was lost (e.g. due to a reorg).
213+
func TestMonitorInvoiceAndHtlcTxStaleConfirmation(t *testing.T) {
214+
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
215+
defer cancel()
216+
217+
mockLnd := test.NewMockLnd()
218+
// Start below the HTLC expiry so the first block notification doesn't
219+
// trigger timeout immediately.
220+
mockLnd.Height = 1
221+
222+
clientKey, err := btcec.NewPrivateKey()
223+
require.NoError(t, err)
224+
serverKey, err := btcec.NewPrivateKey()
225+
require.NoError(t, err)
226+
227+
swapHash := lntypes.Hash{7, 7, 7}
228+
229+
loopIn := &StaticAddressLoopIn{
230+
SwapHash: swapHash,
231+
HtlcCltvExpiry: 20,
232+
InitiationHeight: uint32(mockLnd.Height),
233+
InitiationTime: time.Now(),
234+
ProtocolVersion: version.ProtocolVersion_V0,
235+
ClientPubkey: clientKey.PubKey(),
236+
ServerPubkey: serverKey.PubKey(),
237+
PaymentTimeoutSeconds: 3_600,
238+
}
239+
loopIn.SetState(MonitorInvoiceAndHtlcTx)
240+
241+
mockLnd.Invoices[swapHash] = &lndclient.Invoice{
242+
Hash: swapHash,
243+
State: invoices.ContractOpen,
244+
}
245+
246+
cfg := &Config{
247+
AddressManager: &mockAddressManager{
248+
params: &address.Parameters{
249+
ClientPubkey: clientKey.PubKey(),
250+
ServerPubkey: serverKey.PubKey(),
251+
ProtocolVersion: version.ProtocolVersion_V0,
252+
},
253+
},
254+
ChainNotifier: mockLnd.ChainNotifier,
255+
DepositManager: &noopDepositManager{},
256+
InvoicesClient: mockLnd.LndServices.Invoices,
257+
LndClient: mockLnd.Client,
258+
ChainParams: mockLnd.ChainParams,
259+
}
260+
261+
f, err := NewFSM(ctx, loopIn, cfg, false)
262+
require.NoError(t, err)
263+
264+
resultChan := make(chan fsm.EventType, 1)
265+
go func() {
266+
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
267+
}()
268+
269+
// Wait for invoice and confirmation subscriptions to be registered.
270+
select {
271+
case <-mockLnd.SingleInvoiceSubcribeChannel:
272+
case <-ctx.Done():
273+
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
274+
}
275+
276+
var firstReg *test.ConfRegistration
277+
select {
278+
case firstReg = <-mockLnd.RegisterConfChannel:
279+
case <-ctx.Done():
280+
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
281+
}
282+
283+
// Deliver a confirmation directly on the registration channel to set
284+
// htlcConfirmed = true.
285+
firstReg.ConfChan <- &chainntnfs.TxConfirmation{
286+
Tx: &wire.MsgTx{
287+
TxOut: []*wire.TxOut{
288+
{
289+
PkScript: firstReg.PkScript,
290+
},
291+
},
292+
},
293+
}
294+
295+
// Ensure the confirmation was consumed by the action before injecting
296+
// an error.
297+
require.Eventually(t, func() bool {
298+
return len(firstReg.ConfChan) == 0
299+
}, time.Second, time.Millisecond*10)
300+
301+
// Error the conf stream to force a re-registration; htlcConfirmed must
302+
// be cleared, otherwise we'd wrongly sweep on timeout.
303+
firstReg.ErrChan <- errors.New("conf stream error")
304+
305+
var secondReg *test.ConfRegistration
306+
select {
307+
case secondReg = <-mockLnd.RegisterConfChannel:
308+
case <-ctx.Done():
309+
t.Fatalf("htlc conf was not re-registered: %v", ctx.Err())
310+
}
311+
require.NotEqual(t, firstReg, secondReg)
312+
313+
// Advance chain past the HTLC expiry. With stale htlcConfirmed this
314+
// would take the sweep branch; correct behavior is to time out.
315+
require.NoError(t, mockLnd.NotifyHeight(loopIn.HtlcCltvExpiry+1))
316+
317+
select {
318+
case event := <-resultChan:
319+
require.Equal(t, OnSwapTimedOut, event)
320+
case <-time.After(time.Second):
321+
t.Fatalf("expected swap timeout due to stale conf, got timeout")
322+
}
323+
}
324+
207325
// mockAddressManager is a minimal AddressManager implementation used by the
208326
// test FSM setup.
209327
type mockAddressManager struct {

0 commit comments

Comments
 (0)