staticaddr/loopin: cancel signing for unavailable deposits

Check the originally selected deposit outpoints before signing a static
loop-in HTLC transaction. If any selected outpoint is no longer
available, cancel the swap invoice and fail the signing action instead
of producing signatures for stale inputs.

Wire the lnd-backed checker through loopd and make invoice-monitoring
handle closed subscription channels without spinning.
This commit is contained in:
Slyghtning 2026-06-26 13:21:19 +02:00
parent e02e5bc258
commit dad817a3b2
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
6 changed files with 254 additions and 28 deletions

View file

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

View file

@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/chain"
"github.com/lightninglabs/lndclient" "github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop" "github.com/lightninglabs/loop"
@ -343,7 +344,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
// cancelSwapInvoice best-effort cancels the current swap invoice using a // cancelSwapInvoice best-effort cancels the current swap invoice using a
// detached timeout-limited context. // detached timeout-limited context.
func (f *FSM) cancelSwapInvoice() { func (f *FSM) cancelSwapInvoice() {
if f.loopIn.SwapInvoice == "" { if f.loopIn.SwapHash == (lntypes.Hash{}) {
return return
} }
@ -359,6 +360,68 @@ func (f *FSM) cancelSwapInvoice() {
} }
} }
// 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 // SignHtlcTxAction is called if the htlc was initialized and the server
// provided the necessary information to construct the htlc tx. We sign the htlc // provided the necessary information to construct the htlc tx. We sign the htlc
// tx and send the signatures to the server. // tx and send the signatures to the server.
@ -367,6 +430,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
var err error 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()
return f.HandleError(err)
}
f.loopIn.AddressParams, err = f.loopIn.AddressParams, err =
f.cfg.AddressManager.GetStaticAddressParameters(ctx) f.cfg.AddressManager.GetStaticAddressParameters(ctx)
@ -736,32 +811,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return f.HandleError(err) return f.HandleError(err)
case update := <-invoiceUpdateChan: case update, ok := <-invoiceUpdateChan:
switch update.State { if !ok {
case invoices.ContractOpen: invoiceUpdateChan = nil
case invoices.ContractAccepted: continue
case invoices.ContractSettled: }
f.Debugf("received off-chain payment update "+
"%v", update.State) if event, done := f.handleInvoiceUpdate(update); done {
return event
return OnPaymentReceived }
case invoices.ContractCanceled: case err, ok := <-invoiceErrChan:
// If the invoice was canceled we only log here if !ok {
// since we still need to monitor until the htlc invoiceErrChan = nil
// timed out. continue
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 err = <-invoiceErrChan:
f.Errorf("invoice subscription error: %v", err) f.Errorf("invoice subscription error: %v", err)
case <-ctx.Done(): case <-ctx.Done():

View file

@ -54,10 +54,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
loopIn.SetState(MonitorInvoiceAndHtlcTx) loopIn.SetState(MonitorInvoiceAndHtlcTx)
// Seed the mock invoice store so LookupInvoice succeeds. // Seed the mock invoice store so LookupInvoice succeeds.
mockLnd.Invoices[swapHash] = &lndclient.Invoice{ mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash, Hash: swapHash,
State: invoices.ContractOpen, State: invoices.ContractOpen,
} })
cfg := &Config{ cfg := &Config{
AddressManager: &mockAddressManager{ AddressManager: &mockAddressManager{
@ -270,6 +270,133 @@ func testValidateLoopInContract(_ int32, _ int32) error {
return nil 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 // TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
// created before a server-side rejection is canceled immediately. // created before a server-side rejection is canceled immediately.
func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) {
@ -546,6 +673,24 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context,
return r.err return r.err
} }
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 // initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
// response without standing up the full gRPC client. // response without standing up the full gRPC client.
type initHtlcTestServer struct { type initHtlcTestServer struct {

View file

@ -79,6 +79,10 @@ type Config struct {
// blocks. // blocks.
ChainNotifier lndclient.ChainNotifierClient 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 is the signer client that is used to sign transactions.
Signer lndclient.SignerClient Signer lndclient.SignerClient

View file

@ -155,7 +155,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context,
return nil, fmt.Errorf("invoice: %x not found", hash) 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. // ListTransactions returns all known transactions of the backing lnd node.

View file

@ -225,6 +225,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) {
s.lock.Unlock() 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 // IsDone checks whether all channels have been fully emptied. If not this may
// indicate unexpected behaviour of the code under test. // indicate unexpected behaviour of the code under test.
func (s *LndMockServices) IsDone() error { func (s *LndMockServices) IsDone() error {