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.
This commit is contained in:
Slyghtning 2026-04-22 14:49:35 +02:00
parent 6d70f0fe47
commit 858066dd0c
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
13 changed files with 509 additions and 35 deletions

View file

@ -700,6 +700,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
Store: staticAddressLoopInStore,
WalletKit: d.lnd.WalletKit,
ChainNotifier: d.lnd.ChainNotifier,
TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client),
NotificationManager: notificationManager,
ChainParams: d.lnd.ChainParams,
Signer: d.lnd.Signer,

View file

@ -40,7 +40,9 @@ const (
//
// A single miss can happen during a transient wallet-view gap while lnd is
// processing a replacement or reorg. Requiring two misses keeps that narrow
// race recoverable without leaving vanished deposits selectable forever.
// race recoverable without leaving vanished deposits selectable forever. At
// the default PollInterval, this means a vanished deposit can remain active
// for up to roughly 20 seconds.
vanishedDepositThreshold = 2
)
@ -416,6 +418,7 @@ func (m *Manager) listUnspentWithBestHeight(ctx context.Context) (
return nil, 0, errors.New("unable to get stable best block while " +
"listing deposits")
}
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
// it in our database and manager memory.
func (m *Manager) createNewDeposit(ctx context.Context,

View file

@ -348,3 +348,89 @@ func TestReconcileDepositsReactivatesReappearedReplacedDeposit(t *testing.T) {
require.Zero(t, deposit.ConfirmationHeight)
require.Len(t, manager.activeDeposits, 1)
}
// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement
// UTXO is retained as a new deposit while an in-flight deposit remains tied to
// the outpoint selected by a loop-in.
func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 8,
}
newOutpoint := wire.OutPoint{
Hash: chainhash.Hash{5},
Index: 9,
}
depositID, err := GetRandomDepositID()
require.NoError(t, err)
deposit := &Deposit{
ID: depositID,
OutPoint: oldOutpoint,
Value: btcutil.Amount(100_000),
}
deposit.SetState(LoopingIn)
utxo := &lnwallet.Utxo{
OutPoint: newOutpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
}, nil)
mockAddressManager.On(
"GetStaticAddress", mock.Anything,
).Return((*script.StaticAddress)(nil), nil)
mockStore := new(mockStore)
var createdDeposit *Deposit
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
createdDeposit = args.Get(1).(*Deposit)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
manager.deposits[oldOutpoint] = deposit
fsm := &FSM{}
manager.activeDeposits[oldOutpoint] = fsm
manager.missingDeposits[oldOutpoint] = 1
require.NoError(t, manager.reconcileDeposits(ctx))
require.Same(t, deposit, manager.deposits[oldOutpoint])
require.Equal(t, oldOutpoint, deposit.OutPoint)
require.Equal(t, LoopingIn, deposit.GetState())
replacement, ok := manager.deposits[newOutpoint]
require.True(t, ok)
require.Same(t, createdDeposit, replacement)
require.NotEqual(t, depositID, replacement.ID)
require.Equal(t, newOutpoint, replacement.OutPoint)
require.Equal(t, Deposited, replacement.GetState())
require.Zero(t, replacement.ConfirmationHeight)
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
require.NotSame(t, fsm, manager.activeDeposits[newOutpoint])
require.Empty(t, manager.missingDeposits)
mockStore.AssertNotCalled(
t, "UpdateDeposit", mock.Anything, mock.Anything,
)
}

View file

@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/chain"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
@ -337,6 +338,68 @@ func (f *FSM) cancelSwapInvoice(ctx context.Context) {
}
}
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
// reports whether the update produced a terminal event.
func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
fsm.EventType, bool) {
switch update.State {
case invoices.ContractOpen:
return fsm.NoOp, false
case invoices.ContractAccepted:
return fsm.NoOp, false
case invoices.ContractSettled:
f.Debugf("received off-chain payment update %v", update.State)
return OnPaymentReceived, true
case invoices.ContractCanceled:
// If the invoice was canceled we only log here since we still need
// to monitor until the htlc timed out.
log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash)
return fsm.NoOp, false
default:
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
"canceled", update.State, f.loopIn.SwapHash)
return f.HandleError(err), true
}
}
// originalDepositOutpointUnavailable checks the original selected deposit
// outpoints against the chain backend's UTXO view.
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
bool, error) {
if f.cfg.TxOutChecker == nil {
return false, nil
}
const includeMempool = true
for _, outpointStr := range f.loopIn.DepositOutpoints {
outpoint, err := wire.NewOutPointFromString(outpointStr)
if err != nil {
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
outpointStr, err)
}
txOut, err := f.cfg.TxOutChecker.GetTxOut(
ctx, *outpoint, includeMempool,
)
if err != nil {
return false, fmt.Errorf("unable to get txout %v: %w",
outpoint, err)
}
if txOut == nil {
return true, nil
}
}
return false, nil
}
// SignHtlcTxAction is called if the htlc was initialized and the server
// provided the necessary information to construct the htlc tx. We sign the htlc
// tx and send the signatures to the server.
@ -345,6 +408,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
var err error
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
if err != nil {
return f.HandleError(err)
}
if outpointUnavailable {
err = errors.New("original deposit outpoint no longer available")
f.Warnf("%v, canceling swap invoice", err)
f.cancelSwapInvoice(ctx)
return f.HandleError(err)
}
f.loopIn.AddressParams, err =
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
@ -714,32 +789,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return f.HandleError(err)
case update := <-invoiceUpdateChan:
switch update.State {
case invoices.ContractOpen:
case invoices.ContractAccepted:
case invoices.ContractSettled:
f.Debugf("received off-chain payment update "+
"%v", update.State)
return OnPaymentReceived
case invoices.ContractCanceled:
// If the invoice was canceled we only log here
// since we still need to monitor until the htlc
// timed out.
log.Warnf("invoice for swap hash %v canceled",
f.loopIn.SwapHash)
default:
err = fmt.Errorf("unexpected invoice state %v "+
"for swap hash %v canceled",
update.State, f.loopIn.SwapHash)
return f.HandleError(err)
case update, ok := <-invoiceUpdateChan:
if !ok {
invoiceUpdateChan = nil
continue
}
if event, done := f.handleInvoiceUpdate(update); done {
return event
}
case err, ok := <-invoiceErrChan:
if !ok {
invoiceErrChan = nil
continue
}
case err = <-invoiceErrChan:
f.Errorf("invoice subscription error: %v", err)
case <-ctx.Done():

View file

@ -54,10 +54,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
loopIn.SetState(MonitorInvoiceAndHtlcTx)
// Seed the mock invoice store so LookupInvoice succeeds.
mockLnd.Invoices[swapHash] = &lndclient.Invoice{
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
}
})
cfg := &Config{
AddressManager: &mockAddressManager{
@ -270,6 +270,133 @@ func testValidateLoopInContract(_ int32, _ int32) error {
return nil
}
// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a
// present txout does not trigger the RBF cancellation path.
func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) {
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
}
txOutChecker := &testTxOutChecker{
txOut: &wire.TxOut{Value: 10_000},
}
f := &FSM{
cfg: &Config{
TxOutChecker: txOutChecker,
},
loopIn: &StaticAddressLoopIn{
DepositOutpoints: []string{originalOutpoint.String()},
},
}
unavailable, err := f.originalDepositOutpointUnavailable(t.Context())
require.NoError(t, err)
require.False(t, unavailable)
require.Equal(t, []wire.OutPoint{originalOutpoint}, txOutChecker.outpoints)
require.Equal(t, []bool{true}, txOutChecker.includeMempool)
}
// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a
// pending loop-in is canceled before HTLC signing if GetTxOut with mempool
// awareness reports that one of the originally selected outpoints is gone.
func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{9, 8, 7}
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{originalOutpoint.String()},
}
txOutChecker := &testTxOutChecker{}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
event := f.SignHtlcTxAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "original deposit outpoint no longer available",
)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
require.Equal(t, []wire.OutPoint{originalOutpoint}, txOutChecker.outpoints)
require.Equal(t, []bool{true}, txOutChecker.includeMempool)
}
// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup
// failures are treated as errors, but do not cancel the invoice. The invoice is
// only canceled when GetTxOut explicitly returns nil for an original outpoint.
func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{9, 8, 6}
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{originalOutpoint.String()},
}
txOutChecker := &testTxOutChecker{
err: errors.New("backend unavailable"),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
event := f.SignHtlcTxAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "unable to get txout",
)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice should not have been canceled: %x", hash)
default:
}
}
// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
// created before a server-side rejection is canceled immediately.
func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) {
@ -446,6 +573,24 @@ func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) (
return nil, nil
}
type testTxOutChecker struct {
txOut *wire.TxOut
err error
outpoints []wire.OutPoint
includeMempool []bool
}
// GetTxOut records lookup parameters and returns the configured result.
func (t *testTxOutChecker) GetTxOut(_ context.Context,
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
t.outpoints = append(t.outpoints, outpoint)
t.includeMempool = append(t.includeMempool, includeMempool)
return t.txOut, t.err
}
// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
// response without standing up the full gRPC client.
type initHtlcTestServer struct {

View file

@ -4,6 +4,7 @@ import (
"context"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/deposit"
@ -105,6 +106,16 @@ type QuoteGetter interface {
numDeposits uint32, fast bool) (*loop.LoopInQuote, error)
}
// TxOutChecker checks whether an outpoint is still available in the chain
// backend's UTXO view.
type TxOutChecker interface {
// GetTxOut returns nil if the outpoint is unavailable or spent. The
// includeMempool flag must be passed through to the underlying chain
// backend.
GetTxOut(ctx context.Context, outpoint wire.OutPoint,
includeMempool bool) (*wire.TxOut, error)
}
type NotificationManager interface {
// SubscribeStaticLoopInSweepRequests subscribes to the static loop in
// sweep requests. These are sent by the server to the client to request

View file

@ -93,8 +93,6 @@ type StaticAddressLoopIn struct {
// The outpoints in the format txid:vout that are part of the loop-in
// swap.
// TODO(hieblmi): Replace this with a getter method that fetches the
// outpoints from the deposits.
DepositOutpoints []string
// SelectedAmount is the amount that the user selected for the swap. If

View file

@ -79,6 +79,10 @@ type Config struct {
// blocks.
ChainNotifier lndclient.ChainNotifierClient
// TxOutChecker checks whether selected deposit outpoints are still
// available before we sign an HTLC transaction for them.
TxOutChecker TxOutChecker
// Signer is the signer client that is used to sign transactions.
Signer lndclient.SignerClient
@ -755,8 +759,10 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
}
swap := &StaticAddressLoopIn{
SelectedAmount: req.SelectedAmount,
DepositOutpoints: selectedOutpoints,
SelectedAmount: req.SelectedAmount,
DepositOutpoints: append(
[]string(nil), selectedOutpoints...,
),
Deposits: selectedDeposits,
Label: req.Label,
Initiator: req.Initiator,

View file

@ -507,9 +507,12 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
}
}
depositOutpoints := strings.Split(
swap.DepositOutpoints, OutpointSeparator,
)
var depositOutpoints []string
if swap.DepositOutpoints != "" {
depositOutpoints = strings.Split(
swap.DepositOutpoints, OutpointSeparator,
)
}
timeoutAddressString := swap.HtlcTimeoutSweepAddress
var timeoutAddress btcutil.Address

View file

@ -286,3 +286,76 @@ func TestCreateLoopIn(t *testing.T) {
time.Microsecond,
)
}
// TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins
// keep the original outpoint snapshot stored when the swap was created.
func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) {
ctxb := context.Background()
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)
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d},
Index: 0,
}
currentOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0x5a, 0x6b, 0x7c, 0x8d},
Index: 1,
}
d := &deposit.Deposit{
ID: depositID,
OutPoint: oldOutpoint,
Value: btcutil.Amount(100_000),
TimeOutSweepPkScript: []byte{
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41,
},
}
require.NoError(t, depositStore.CreateDeposit(ctxb, d))
d.SetState(deposit.LoopingIn)
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
_, 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}
swap := StaticAddressLoopIn{
SwapHash: swapHash,
SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4},
DepositOutpoints: []string{oldOutpoint.String()},
Deposits: []*deposit.Deposit{d},
ClientPubkey: clientPubKey,
ServerPubkey: serverPubKey,
HtlcTimeoutSweepAddress: addr,
}
swap.SetState(SignHtlcTx)
require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap))
d.OutPoint = currentOutpoint
d.ConfirmationHeight = 42
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash)
require.NoError(t, err)
require.Equal(
t, []string{oldOutpoint.String()},
storedSwap.DepositOutpoints,
)
require.Len(t, storedSwap.Deposits, 1)
require.Equal(t, currentOutpoint, storedSwap.Deposits[0].OutPoint)
require.Equal(t, int64(42), storedSwap.Deposits[0].ConfirmationHeight)
}

View file

@ -0,0 +1,72 @@
package loopin
import (
"context"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
)
// lndTxOutChecker checks outpoint availability using lnd's wallet transaction
// view. It returns nil for outputs already spent by a wallet-known transaction.
type lndTxOutChecker struct {
client lndclient.LightningClient
}
// NewLndTxOutChecker creates a TxOutChecker backed by lnd.
func NewLndTxOutChecker(client lndclient.LightningClient) TxOutChecker {
return &lndTxOutChecker{
client: client,
}
}
// GetTxOut returns the tx output if lnd's transaction view still reports the
// outpoint as unspent.
func (c *lndTxOutChecker) GetTxOut(ctx context.Context,
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
endHeight := int32(0)
if includeMempool {
endHeight = -1
}
// We need lnd's wallet transaction view rather than only the funding
// transaction: a matching previous outpoint tells us the deposit has
// already been spent by a wallet-known transaction. When mempool spends
// matter, lnd exposes them through ListTransactions with endHeight=-1.
txs, err := c.client.ListTransactions(ctx, 0, endHeight)
if err != nil {
return nil, err
}
outpointStr := outpoint.String()
for _, tx := range txs {
for _, prevOutpoint := range tx.PreviousOutpoints {
if prevOutpoint.GetOutpoint() == outpointStr {
return nil, nil
}
}
}
for _, tx := range txs {
if tx.Tx == nil {
continue
}
txHash := tx.TxHash
if txHash == "" {
txHash = tx.Tx.TxHash().String()
}
if txHash != outpoint.Hash.String() {
continue
}
if int(outpoint.Index) >= len(tx.Tx.TxOut) {
return nil, nil
}
return tx.Tx.TxOut[outpoint.Index], nil
}
return nil, nil
}

View file

@ -170,7 +170,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context,
return nil, fmt.Errorf("invoice: %x not found", hash)
}
return inv, nil
invoiceCopy := *inv
return &invoiceCopy, nil
}
// ListTransactions returns all known transactions of the backing lnd node.

View file

@ -218,6 +218,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) {
s.lock.Unlock()
}
// SetInvoice stores a copy of the given invoice in the mock invoice store.
func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) {
s.lock.Lock()
defer s.lock.Unlock()
invoiceCopy := *invoice
s.Invoices[invoice.Hash] = &invoiceCopy
}
// IsDone checks whether all channels have been fully emptied. If not this may
// indicate unexpected behaviour of the code under test.
func (s *LndMockServices) IsDone() error {