Skip to content

Commit 06b2a11

Browse files
committed
staticaddr: cancel loop-ins when deposit inputs vanish
Keep replacement UTXOs as fresh deposits while preserving the original deposit record and selected outpoint snapshot for pending swaps. Before signing a static loop-in HTLC, check each original selected outpoint with GetTxOut(..., includeMempool=true). Cancel the pending invoice only when that check reports an original outpoint unavailable; lookup errors fail the action without canceling so transient chain backend errors do not incorrectly abandon the swap. Keep recovered loop-ins using their stored outpoint snapshot and cover replacement discovery and cancellation in tests.
1 parent 6f5ccf7 commit 06b2a11

13 files changed

Lines changed: 506 additions & 33 deletions

loopd/daemon.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
679679
Store: staticAddressLoopInStore,
680680
WalletKit: d.lnd.WalletKit,
681681
ChainNotifier: d.lnd.ChainNotifier,
682+
TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client),
682683
NotificationManager: notificationManager,
683684
ChainParams: d.lnd.ChainParams,
684685
Signer: d.lnd.Signer,

staticaddr/deposit/manager.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ const (
4040
//
4141
// A single miss can happen during a transient wallet-view gap while lnd is
4242
// processing a replacement or reorg. Requiring two misses keeps that narrow
43-
// race recoverable without leaving vanished deposits selectable forever.
43+
// race recoverable without leaving vanished deposits selectable forever. At
44+
// the default PollInterval, this means a vanished deposit can remain active
45+
// for up to roughly 20 seconds.
4446
vanishedDepositThreshold = 2
4547
)
4648

@@ -416,6 +418,7 @@ func (m *Manager) listUnspentWithBestHeight(ctx context.Context) (
416418
return nil, 0, errors.New("unable to get stable best block while " +
417419
"listing deposits")
418420
}
421+
419422
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
420423
// it in our database and manager memory.
421424
func (m *Manager) createNewDeposit(ctx context.Context,

staticaddr/deposit/manager_reconcile_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,89 @@ func TestReconcileDepositsReactivatesReappearedReplacedDeposit(t *testing.T) {
344344
require.Zero(t, deposit.ConfirmationHeight)
345345
require.Len(t, manager.activeDeposits, 1)
346346
}
347+
348+
// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement
349+
// UTXO is retained as a new deposit while an in-flight deposit remains tied to
350+
// the outpoint selected by a loop-in.
351+
func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) {
352+
ctx := context.Background()
353+
mockLnd := test.NewMockLnd()
354+
oldOutpoint := wire.OutPoint{
355+
Hash: chainhash.Hash{4},
356+
Index: 8,
357+
}
358+
newOutpoint := wire.OutPoint{
359+
Hash: chainhash.Hash{5},
360+
Index: 9,
361+
}
362+
363+
depositID, err := GetRandomDepositID()
364+
require.NoError(t, err)
365+
366+
deposit := &Deposit{
367+
ID: depositID,
368+
OutPoint: oldOutpoint,
369+
Value: btcutil.Amount(100_000),
370+
}
371+
deposit.SetState(LoopingIn)
372+
373+
utxo := &lnwallet.Utxo{
374+
OutPoint: newOutpoint,
375+
Value: deposit.Value,
376+
Confirmations: 0,
377+
}
378+
379+
mockAddressManager := new(mockAddressManager)
380+
mockAddressManager.On(
381+
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
382+
).Return([]*lnwallet.Utxo{utxo}, nil)
383+
mockAddressManager.On(
384+
"GetStaticAddressParameters", mock.Anything,
385+
).Return(&address.Parameters{
386+
ProtocolVersion: version.ProtocolVersion_V0,
387+
}, nil)
388+
mockAddressManager.On(
389+
"GetStaticAddress", mock.Anything,
390+
).Return((*script.StaticAddress)(nil), nil)
391+
392+
mockStore := new(mockStore)
393+
var createdDeposit *Deposit
394+
mockStore.On(
395+
"CreateDeposit", mock.Anything, mock.Anything,
396+
).Return(nil).Run(func(args mock.Arguments) {
397+
createdDeposit = args.Get(1).(*Deposit)
398+
})
399+
400+
manager := NewManager(&ManagerConfig{
401+
AddressManager: mockAddressManager,
402+
Store: mockStore,
403+
WalletKit: mockLnd.WalletKit,
404+
Signer: mockLnd.Signer,
405+
})
406+
manager.deposits[oldOutpoint] = deposit
407+
fsm := &FSM{}
408+
manager.activeDeposits[oldOutpoint] = fsm
409+
manager.missingDeposits[oldOutpoint] = 1
410+
411+
require.NoError(t, manager.reconcileDeposits(ctx))
412+
413+
require.Same(t, deposit, manager.deposits[oldOutpoint])
414+
require.Equal(t, oldOutpoint, deposit.OutPoint)
415+
require.Equal(t, LoopingIn, deposit.GetState())
416+
417+
replacement, ok := manager.deposits[newOutpoint]
418+
require.True(t, ok)
419+
require.Same(t, createdDeposit, replacement)
420+
require.NotEqual(t, depositID, replacement.ID)
421+
require.Equal(t, newOutpoint, replacement.OutPoint)
422+
require.Equal(t, Deposited, replacement.GetState())
423+
require.Zero(t, replacement.ConfirmationHeight)
424+
425+
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
426+
require.NotSame(t, fsm, manager.activeDeposits[newOutpoint])
427+
require.Empty(t, manager.missingDeposits)
428+
429+
mockStore.AssertNotCalled(
430+
t, "UpdateDeposit", mock.Anything, mock.Anything,
431+
)
432+
}

staticaddr/loopin/actions.go

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
1313
"github.com/btcsuite/btcd/btcutil"
1414
"github.com/btcsuite/btcd/txscript"
15+
"github.com/btcsuite/btcd/wire"
1516
"github.com/btcsuite/btcwallet/chain"
1617
"github.com/lightninglabs/lndclient"
1718
"github.com/lightninglabs/loop"
@@ -337,6 +338,68 @@ func (f *FSM) cancelSwapInvoice(ctx context.Context) {
337338
}
338339
}
339340

341+
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
342+
// reports whether the update produced a terminal event.
343+
func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
344+
fsm.EventType, bool) {
345+
346+
switch update.State {
347+
case invoices.ContractOpen:
348+
return fsm.NoOp, false
349+
350+
case invoices.ContractAccepted:
351+
return fsm.NoOp, false
352+
353+
case invoices.ContractSettled:
354+
f.Debugf("received off-chain payment update %v", update.State)
355+
return OnPaymentReceived, true
356+
357+
case invoices.ContractCanceled:
358+
// If the invoice was canceled we only log here since we still need
359+
// to monitor until the htlc timed out.
360+
log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash)
361+
return fsm.NoOp, false
362+
363+
default:
364+
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
365+
"canceled", update.State, f.loopIn.SwapHash)
366+
return f.HandleError(err), true
367+
}
368+
}
369+
370+
// originalDepositOutpointUnavailable checks the original selected deposit
371+
// outpoints against the chain backend's UTXO view.
372+
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
373+
bool, error) {
374+
375+
if f.cfg.TxOutChecker == nil {
376+
return false, nil
377+
}
378+
379+
const includeMempool = true
380+
for _, outpointStr := range f.loopIn.DepositOutpoints {
381+
outpoint, err := wire.NewOutPointFromString(outpointStr)
382+
if err != nil {
383+
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
384+
outpointStr, err)
385+
}
386+
387+
txOut, err := f.cfg.TxOutChecker.GetTxOut(
388+
ctx, *outpoint, includeMempool,
389+
)
390+
if err != nil {
391+
return false, fmt.Errorf("unable to get txout %v: %w",
392+
outpoint, err)
393+
}
394+
395+
if txOut == nil {
396+
return true, nil
397+
}
398+
}
399+
400+
return false, nil
401+
}
402+
340403
// SignHtlcTxAction is called if the htlc was initialized and the server
341404
// provided the necessary information to construct the htlc tx. We sign the htlc
342405
// tx and send the signatures to the server.
@@ -345,6 +408,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
345408

346409
var err error
347410

411+
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
412+
if err != nil {
413+
return f.HandleError(err)
414+
}
415+
if outpointUnavailable {
416+
err = errors.New("original deposit outpoint no longer available")
417+
f.Warnf("%v, canceling swap invoice", err)
418+
f.cancelSwapInvoice(ctx)
419+
420+
return f.HandleError(err)
421+
}
422+
348423
f.loopIn.AddressParams, err =
349424
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
350425

@@ -714,32 +789,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
714789

715790
return f.HandleError(err)
716791

717-
case update := <-invoiceUpdateChan:
718-
switch update.State {
719-
case invoices.ContractOpen:
720-
case invoices.ContractAccepted:
721-
case invoices.ContractSettled:
722-
f.Debugf("received off-chain payment update "+
723-
"%v", update.State)
724-
725-
return OnPaymentReceived
726-
727-
case invoices.ContractCanceled:
728-
// If the invoice was canceled we only log here
729-
// since we still need to monitor until the htlc
730-
// timed out.
731-
log.Warnf("invoice for swap hash %v canceled",
732-
f.loopIn.SwapHash)
792+
case update, ok := <-invoiceUpdateChan:
793+
if !ok {
794+
invoiceUpdateChan = nil
795+
continue
796+
}
733797

734-
default:
735-
err = fmt.Errorf("unexpected invoice state %v "+
736-
"for swap hash %v canceled",
737-
update.State, f.loopIn.SwapHash)
798+
if event, done := f.handleInvoiceUpdate(update); done {
799+
return event
800+
}
738801

739-
return f.HandleError(err)
802+
case err, ok := <-invoiceErrChan:
803+
if !ok {
804+
invoiceErrChan = nil
805+
continue
740806
}
741807

742-
case err = <-invoiceErrChan:
743808
f.Errorf("invoice subscription error: %v", err)
744809

745810
case <-ctx.Done():

0 commit comments

Comments
 (0)