From b87494fa23b8aeaa59caa09dda11fd4525fae42e Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Thu, 12 Nov 2026 11:40:20 +0200 Subject: [PATCH 1/2] staticaddr/loopin: confirm only the htlc tx that spent deposits The loop-in monitor registers for the htlc confirmation by pkScript only, so lnd reports the first confirmed tx that pays to the htlc script. The server knows that script and can confirm a dust output to it from its own funds, without spending the deposits and without paying the invoice. The monitor took that as the htlc confirmation and locked the deposits for a timeout sweep of an htlc output that doesn't exist. The deposits had no way out of that state. Now a confirmation only counts if its tx spends exactly the deposits the htlc tx was signed for and pays to the htlc. lnd reports a script match only once, so after a tx that doesn't qualify, the monitor watches the spend of the first deposit and registers the confirmation of the htlc tx that spent it by txid. A missing confirmation is rejected the same way. The htlc tx can spend the deposits before the monitor sees its confirmation, for example if the monitor starts after the htlc expiry. Releasing the deposits then would abandon the htlc. So once the spend watch reports the htlc tx, the htlc timeout waits for its confirmation. And before the timeout releases the deposits, it asks lnd's wallet whether an htlc tx spent them. If none did, the swap times out and the deposits are released, as before. A reorg drops the txid and the note that the deposits are spent. The next match then goes through the same checks, and the swap doesn't wait forever for an htlc tx that is gone, because the timeout asks lnd again. The spend watch reports its own reorgs, since lnd before v0.21 sends no reorg for a confirmation it didn't report yet. After a spend reorg the monitor watches the deposit spend again, so it also finds another signed fee variant of the htlc tx. --- staticaddr/loopin/actions.go | 240 +++++- staticaddr/loopin/actions_test.go | 914 ++++++++++++++++++++++- staticaddr/loopin/risk_rejection_test.go | 8 +- test/chainnotifier_mock.go | 23 +- 4 files changed, 1170 insertions(+), 15 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 09aead3fb..85710fdd3 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" @@ -562,6 +563,17 @@ func (f *FSM) depositOutpoints() ([]wire.OutPoint, error) { return outpoints, nil } +// htlcTxInputs returns the deposit outpoints the htlc tx spends. That is the +// outpoint snapshot the htlc tx was signed for. The deposit rows of a +// recovered swap can differ from it. Swaps without a snapshot use the rows. +func (f *FSM) htlcTxInputs() ([]wire.OutPoint, error) { + if len(f.loopIn.DepositOutpoints) > 0 { + return f.depositOutpoints() + } + + return f.loopIn.Outpoints(), nil +} + // originalDepositOutpointUnavailable reports whether an original selected // deposit outpoint is missing from lnd's wallet transactions or spent by one // of them. This is lnd's wallet view, not the chain's UTXO set, and it doesn't @@ -600,14 +612,17 @@ func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) ( func (f *FSM) htlcTxSpentDeposits(ctx context.Context, htlcPkScript []byte) (bool, error) { - if f.cfg.TxOutChecker == nil || len(f.loopIn.DepositOutpoints) == 0 { + if f.cfg.TxOutChecker == nil { return false, nil } - outpoints, err := f.depositOutpoints() + outpoints, err := f.htlcTxInputs() if err != nil { return false, err } + if len(outpoints) == 0 { + return false, nil + } spent, err := f.cfg.TxOutChecker.SpentByTxPayingTo( ctx, outpoints, htlcPkScript, @@ -939,13 +954,18 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // Subscribe to htlc tx confirmation. reorgChan := make(chan struct{}, 1) + + // htlcTxid is set once the htlc tx is known from the spend of the + // deposits. The confirmation is then registered for that tx only. + var htlcTxid *chainhash.Hash + // registerHtlcConf registers for the HTLC transaction confirmation using // the current reorg channel. registerHtlcConf := func() (chan *chainntnfs.TxConfirmation, chan error, error) { return f.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, nil, htlc.PkScript, defaultConfTarget, + ctx, htlcTxid, htlc.PkScript, defaultConfTarget, int32(f.loopIn.InitiationHeight), lndclient.WithReOrgChan(reorgChan), ) @@ -963,6 +983,40 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return retryMonitor(err) } + // registerDepositSpend registers for the spend of the first deposit. + // It is used to find the htlc tx after a confirmation of a tx that + // pays to the htlc but isn't the htlc tx. The spend watch reports its + // own reorgs, because lnd before v0.21 sends no reorg for a + // confirmation it didn't report yet. Each registration has its own + // reorg channel, so a replaced registration can't report a reorg a + // second time. + var ( + depositSpendChan chan *chainntnfs.SpendDetail + depositSpendErrChan chan error + depositSpendReorgChan chan struct{} + ) + registerDepositSpend := func() error { + outpoints, err := f.htlcTxInputs() + if err != nil { + return err + } + if len(outpoints) == 0 || f.loopIn.AddressParams == nil { + return errors.New("no deposits or static address " + + "parameters to watch") + } + + depositSpendReorgChan = make(chan struct{}, 1) + depositSpendChan, depositSpendErrChan, err = + f.cfg.ChainNotifier.RegisterSpendNtfn( + ctx, &outpoints[0], + f.loopIn.AddressParams.PkScript, + int32(f.loopIn.InitiationHeight), + lndclient.WithReOrgChan(depositSpendReorgChan), + ) + + return err + } + // Subscribe to new blocks. registerBlocks := f.cfg.ChainNotifier.RegisterBlockEpochNtfn blockChan, blockChanErr, err := registerBlocks(ctx) @@ -1188,8 +1242,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfirmed := false - // depositsSpent is set when a rejection finds that the htlc tx spent - // the deposits before its confirmation arrived. + // depositsSpent is set when the htlc tx is known to have spent the + // deposits before its confirmation arrived. The swap then waits for + // the confirmation instead of releasing the deposits. A reorg clears + // it, because the htlc tx may be gone for good and the swap must not + // wait for it forever. That is safe, because the htlc timeout asks + // lnd at every block before it releases the deposits, so an htlc tx + // that is only confirming again keeps them locked. depositsSpent := false // handleRiskRejected cancels the invoice after a server rejection. The @@ -1282,7 +1341,43 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, for { select { - case <-htlcConfChan: + case conf := <-htlcConfChan: + // Anyone who knows the htlc script can confirm a tx + // that pays to it. Only the htlc tx we signed also + // spends the deposits. + if conf == nil || !f.isHtlcTx(conf.Tx, htlc.PkScript) { + switch { + case conf == nil || conf.Tx == nil: + f.Warnf("ignoring htlc confirmation " + + "without a tx") + + default: + f.Warnf("ignoring confirmation of "+ + "tx %v at height %d that "+ + "pays to the htlc but isn't "+ + "the htlc tx", + conf.Tx.TxHash(), + conf.BlockHeight) + } + + // lnd reports a script match only once, so + // find the htlc tx from the deposit spend. + htlcConfChan = nil + err = registerDepositSpend() + if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + + err = fmt.Errorf("unable to watch "+ + "deposit spend: %w", err) + + return retryMonitor(err) + } + + continue + } + f.Infof("htlc tx confirmed") htlcConfirmed = true @@ -1323,8 +1418,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, case <-reorgChan: // A reorg happened. We invalidate a previous htlc // confirmation and re-register for the next - // confirmation. + // confirmation. The htlc tx found from the deposit + // spend may have been reorged out too, so the htlc + // timeout asks lnd again whether it spent the + // deposits. htlcConfirmed = false + htlcTxid = nil + depositsSpent = false htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { @@ -1338,6 +1438,80 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return retryMonitor(err) } + case spend := <-depositSpendChan: + depositSpendChan = nil + if spend == nil || + !f.isHtlcTx(spend.SpendingTx, htlc.PkScript) { + + switch { + case spend == nil || spend.SpendingTx == nil: + f.Warnf("ignoring deposit spend " + + "without a tx") + + default: + f.Warnf("deposit spent by tx %v at "+ + "height %d that isn't the "+ + "htlc tx", + spend.SpendingTx.TxHash(), + spend.SpendingHeight) + } + + continue + } + + // The htlc tx spent the deposits, so the htlc timeout + // must wait for its confirmation. + txid := spend.SpendingTx.TxHash() + htlcTxid = &txid + depositsSpent = true + f.Infof("htlc tx %v spent the deposits at height %d, "+ + "waiting for its confirmation", txid, + spend.SpendingHeight) + + htlcConfChan, htlcErrConfChan, err = registerHtlcConf() + if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + + err = fmt.Errorf("unable to monitor htlc tx "+ + "confirmation: %w", err) + + return retryMonitor(err) + } + + case err = <-depositSpendErrChan: + if ctx.Err() != nil { + return fsm.NoOp + } + + return retryMonitor(fmt.Errorf("deposit spend "+ + "subscription error: %w", err)) + + case <-depositSpendReorgChan: + // The tx that spent the deposit was reorged out. Drop + // the htlc tx found from it and watch the spend again, + // the deposits can be spent by another signed fee + // variant of the htlc tx with a different txid. + f.Infof("deposit spend reorged out, watching the " + + "deposit spend again") + + htlcConfirmed = false + htlcTxid = nil + depositsSpent = false + + err = registerDepositSpend() + if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + + err = fmt.Errorf("unable to watch deposit "+ + "spend: %w", err) + + return retryMonitor(err) + } + case <-deadlineChan: deadlineChan = nil @@ -1427,9 +1601,26 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, if !htlcConfirmed && !anyDepositInState(deposit.SweepHtlcTimeout) { + // The htlc tx can spend the deposits before we + // see its confirmation, for example if the + // monitor starts after the htlc expiry. Ask + // lnd before releasing them. The answer isn't + // kept, so that the swap stops waiting if lnd + // no longer lists the htlc tx at a later + // block. + waitForHtlc := depositsSpent + if !waitForHtlc { + waitForHtlc, err = f.htlcTxSpentDeposits( + ctx, htlc.PkScript, + ) + if err != nil { + return retryMonitor(err) + } + } + // The htlc spent the deposits, wait for its // confirmation to sweep it. - if depositsSpent { + if waitForHtlc { f.Debugf("htlc timed out, waiting for the " + "htlc confirmation") @@ -1518,6 +1709,39 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } } +// isHtlcTx reports whether tx spends exactly the deposits the htlc tx was +// signed for and pays to the htlc pkScript. Anyone who knows the htlc script +// can create a tx that pays to it, but only the htlc tx we signed also spends +// the deposits. +func (f *FSM) isHtlcTx(tx *wire.MsgTx, htlcPkScript []byte) bool { + outpoints, err := f.htlcTxInputs() + if err != nil { + f.Warnf("unable to get the htlc tx inputs: %v", err) + + return false + } + if tx == nil || len(outpoints) == 0 || + len(tx.TxIn) != len(outpoints) { + + return false + } + + deposits := make(map[wire.OutPoint]struct{}, len(outpoints)) + for _, outpoint := range outpoints { + deposits[outpoint] = struct{}{} + } + + for _, txIn := range tx.TxIn { + if _, ok := deposits[txIn.PreviousOutPoint]; !ok { + return false + } + + delete(deposits, txIn.PreviousOutPoint) + } + + return paysTo(tx, htlcPkScript) +} + // htlcTimeoutSweepRetryDelay is the delay between retries when publishing the // htlc timeout sweep transaction fails. const htlcTimeoutSweepRetryDelay = time.Hour diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 198deac34..d60ebd000 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -20,6 +20,7 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" @@ -1095,6 +1096,7 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { f, err := NewFSM(ctx, loopIn, cfg, false) require.NoError(t, err) + htlcConf := htlcConfirmation(t, f) resultChan := make(chan fsm.EventType, 1) go func() { @@ -1113,7 +1115,7 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { case <-ctx.Done(): t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- htlcConf select { case hash := <-mockLnd.FailInvoiceChannel: @@ -2771,6 +2773,7 @@ func TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails( ) f.loopIn.HtlcCltvExpiry = mockLnd.Height depositMgr.err = errors.New("transition failed") + htlcConf := htlcConfirmation(t, f) resultChan := make(chan fsm.EventType, 1) go func() { @@ -2790,7 +2793,7 @@ func TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails( t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- htlcConf select { case transition := <-depositMgr.transitionChan: @@ -2860,6 +2863,7 @@ func TestMonitorInvoiceAndHtlcTxRetriesOnlyPendingTimeoutDeposits(t *testing.T) return nil } depositMgr.transitionChan = make(chan depositTransition, 2) + htlcConf := htlcConfirmation(t, f) runMonitor := func(runCtx context.Context) chan fsm.EventType { resultChan := make(chan fsm.EventType, 1) @@ -2881,7 +2885,7 @@ func TestMonitorInvoiceAndHtlcTxRetriesOnlyPendingTimeoutDeposits(t *testing.T) t.Fatalf("htlc conf registration not received: %v", runCtx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- htlcConf return resultChan } @@ -3046,6 +3050,895 @@ func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context, return f, depositMgr } +// htlcConfirmation returns a confirmation of an htlc tx that spends the +// deposits the loop-in was signed for and pays to its htlc. +func htlcConfirmation(t *testing.T, f *FSM) *chainntnfs.TxConfirmation { + t.Helper() + + htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams) + require.NoError(t, err) + + outpoints, err := f.htlcTxInputs() + require.NoError(t, err) + + tx := wire.NewMsgTx(2) + for _, outpoint := range outpoints { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + } + tx.AddTxOut(&wire.TxOut{Value: 100_000, PkScript: htlc.PkScript}) + + return &chainntnfs.TxConfirmation{Tx: tx} +} + +// TestMonitorInvoiceAndHtlcTxIgnoresDecoyHtlcConf verifies that a confirmation +// of a tx that pays to the htlc script but doesn't spend the deposits doesn't +// lock the deposits for the htlc timeout sweep. The htlc confirmation is +// registered by pkScript only, so lnd reports the first tx paying to the htlc +// script, which the server can create from its own funds. The monitor then +// watches the deposit spend and confirms the htlc tx by its txid. A deposit +// spend by a tx that isn't the htlc tx doesn't confirm the htlc either. +func TestMonitorInvoiceAndHtlcTxIgnoresDecoyHtlcConf(t *testing.T) { + // dustDecoy pays a dust output to the htlc from an input the server + // controls, without spending the deposits. + dustDecoy := func(htlcPkScript []byte) *chainntnfs.TxConfirmation { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xde, 0xad}, + }, + }) + tx.AddTxOut(&wire.TxOut{Value: 330, PkScript: htlcPkScript}) + + return &chainntnfs.TxConfirmation{Tx: tx} + } + + tests := []struct { + name string + decoy func(htlcPkScript []byte) *chainntnfs.TxConfirmation + + // spentByHtlc is whether the htlc tx spends the deposits, + // instead of a tx that doesn't pay to the htlc. + spentByHtlc bool + }{ + { + name: "dust output from foreign input", + decoy: dustDecoy, + spentByHtlc: true, + }, + { + name: "nil confirmation", + decoy: func([]byte) *chainntnfs.TxConfirmation { + return nil + }, + spentByHtlc: true, + }, + { + name: "deposits spent by other tx", + decoy: dustDecoy, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + testMonitorIgnoresDecoyHtlcConf( + t, tc.decoy, tc.spentByHtlc, + ) + }) + } +} + +// testMonitorIgnoresDecoyHtlcConf delivers the decoy confirmation and then the +// deposit spend, and checks that only the htlc tx locks the deposits. +func testMonitorIgnoresDecoyHtlcConf(t *testing.T, + decoy func(htlcPkScript []byte) *chainntnfs.TxConfirmation, + spentByHtlc bool) { + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{32, 33, 34} + + // With the invoice canceled, an htlc confirmation locks the deposits + // for the timeout sweep right away. + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractCanceled, + }) + + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone, + mockLnd.LndServices.Invoices, + ) + depositMgr.transitionChan = make(chan depositTransition, 4) + + dep := f.loopIn.Deposits[0] + dep.OutPoint = wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} + dep.SetState(deposit.LoopingIn) + depositMgr.active = map[wire.OutPoint]*deposit.Deposit{ + dep.OutPoint: dep, + } + f.loopIn.DepositOutpoints = []string{dep.OutPoint.String()} + f.loopIn.AddressParams = &script.Parameters{ + PkScript: []byte{0x51, 0x20, 0x01}, + } + + htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams) + require.NoError(t, err) + htlcConf := htlcConfirmation(t, f) + htlcTxid := htlcConf.Tx.TxHash() + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + select { + case <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + var confReg *test.ConfRegistration + select { + case confReg = <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } + require.Nil(t, confReg.TxID) + + confReg.ConfChan <- decoy(htlc.PkScript) + + // The monitor must ignore the decoy and watch the deposit spend + // instead of locking the deposits. + var spendReg *test.SpendRegistration + select { + case transition := <-depositMgr.transitionChan: + t.Fatalf("decoy confirmation moved deposits to %v", + transition.state) + + case spendReg = <-mockLnd.RegisterSpendChannel: + + case <-ctx.Done(): + t.Fatalf("deposit spend registration not received: %v", + ctx.Err()) + } + require.Equal(t, dep.OutPoint, *spendReg.Outpoint) + require.Equal(t, f.loopIn.AddressParams.PkScript, spendReg.PkScript) + + if !spentByHtlc { + otherTx := wire.NewMsgTx(2) + otherTx.AddTxIn(&wire.TxIn{PreviousOutPoint: dep.OutPoint}) + otherTx.AddTxOut(&wire.TxOut{ + Value: 100_000, + PkScript: []byte{0x51}, + }) + spendReg.SpendChannel <- &chainntnfs.SpendDetail{ + SpendingTx: otherTx, + } + + select { + case reg := <-mockLnd.RegisterConfChannel: + t.Fatalf("conf registered for tx %v that isn't the "+ + "htlc tx", reg.TxID) + + case transition := <-depositMgr.transitionChan: + t.Fatalf("deposit spend moved deposits to %v", + transition.state) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + require.Equal(t, fsm.NoOp, <-resultChan) + + return + } + + // The htlc tx spends the deposit, and its confirmation is then + // registered by txid. + spendReg.SpendChannel <- &chainntnfs.SpendDetail{ + SpendingTx: htlcConf.Tx, + } + + select { + case confReg = <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc txid conf registration not received: %v", + ctx.Err()) + } + require.NotNil(t, confReg.TxID) + require.Equal(t, htlcTxid, *confReg.TxID) + require.Empty(t, depositMgr.transitionChan) + + confReg.ConfChan <- htlcConf + + select { + case transition := <-depositMgr.transitionChan: + require.Equal(t, deposit.SweepHtlcTimeout, transition.state) + + case <-ctx.Done(): + t.Fatalf("deposits were not locked after the htlc "+ + "confirmation: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// decoyMonitorTest runs the monitor of a swap with a canceled invoice and one +// deposit. The tests deliver a decoy confirmation and then drive the deposit +// spend watch. +type decoyMonitorTest struct { + t *testing.T + ctx context.Context + mockLnd *test.LndMockServices + f *FSM + depositMgr *recordingDepositManager + txOuts *recordingTxOutChecker + resultChan chan fsm.EventType +} + +func newDecoyMonitorTest(t *testing.T, ctx context.Context) *decoyMonitorTest { + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{32, 33, 35} + + // With the invoice canceled, an htlc confirmation locks the deposits + // for the timeout sweep right away. + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractCanceled, + }) + + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone, + mockLnd.LndServices.Invoices, + ) + depositMgr.transitionChan = make(chan depositTransition, 4) + + dep := f.loopIn.Deposits[0] + dep.OutPoint = wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} + dep.SetState(deposit.LoopingIn) + depositMgr.active = map[wire.OutPoint]*deposit.Deposit{ + dep.OutPoint: dep, + } + f.loopIn.DepositOutpoints = []string{dep.OutPoint.String()} + f.loopIn.AddressParams = &script.Parameters{ + PkScript: []byte{0x51, 0x20, 0x01}, + } + + // The htlc expires two blocks from now. + f.loopIn.HtlcCltvExpiry = mockLnd.Height + 2 + + txOuts := &recordingTxOutChecker{} + f.cfg.TxOutChecker = txOuts + + return &decoyMonitorTest{ + t: t, + ctx: ctx, + mockLnd: mockLnd, + f: f, + depositMgr: depositMgr, + txOuts: txOuts, + resultChan: make(chan fsm.EventType, 1), + } +} + +// htlcTx returns a fee variant of the htlc tx. Every variant spends the same +// deposits and pays to the htlc, the output value makes the txid differ. +func (d *decoyMonitorTest) htlcTx(value int64) *wire.MsgTx { + tx := htlcConfirmation(d.t, d.f).Tx + tx.TxOut[0].Value = value + + return tx +} + +// start runs the monitor and returns the htlc confirmation registration. +func (d *decoyMonitorTest) start() *test.ConfRegistration { + go func() { + d.resultChan <- d.f.MonitorInvoiceAndHtlcTxAction(d.ctx, nil) + }() + + select { + case <-d.mockLnd.SingleInvoiceSubcribeChannel: + case <-d.ctx.Done(): + d.t.Fatalf("invoice subscription not registered: %v", + d.ctx.Err()) + } + + reg := d.confRegistration() + require.Nil(d.t, reg.TxID) + + return reg +} + +// confRegistration waits for the next htlc confirmation registration. +func (d *decoyMonitorTest) confRegistration() *test.ConfRegistration { + select { + case reg := <-d.mockLnd.RegisterConfChannel: + return reg + + case event := <-d.resultChan: + d.t.Fatalf("monitor exited with %v: %v", event, + d.f.LastActionError) + + case <-d.ctx.Done(): + d.t.Fatalf("htlc conf registration not received: %v", + d.ctx.Err()) + } + + return nil +} + +// spendRegistration waits for the next deposit spend registration. +func (d *decoyMonitorTest) spendRegistration() *test.SpendRegistration { + select { + case reg := <-d.mockLnd.RegisterSpendChannel: + require.NotNil(d.t, reg.ReorgChan) + + return reg + + case event := <-d.resultChan: + d.t.Fatalf("monitor exited with %v: %v", event, + d.f.LastActionError) + + case <-d.ctx.Done(): + d.t.Fatalf("deposit spend registration not received: %v", + d.ctx.Err()) + } + + return nil +} + +// decoy confirms a dust output to the htlc that doesn't spend the deposits +// and returns the deposit spend registration that follows. +func (d *decoyMonitorTest) decoy( + reg *test.ConfRegistration) *test.SpendRegistration { + + htlc, err := d.f.loopIn.getHtlc(d.f.cfg.ChainParams) + require.NoError(d.t, err) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xde, 0xad}, + }, + }) + tx.AddTxOut(&wire.TxOut{Value: 330, PkScript: htlc.PkScript}) + reg.ConfChan <- &chainntnfs.TxConfirmation{Tx: tx} + + return d.spendRegistration() +} + +// spend reports the tx as the spender of the watched deposit and returns the +// confirmation registration for its txid. +func (d *decoyMonitorTest) spend(reg *test.SpendRegistration, + tx *wire.MsgTx) *test.ConfRegistration { + + reg.SpendChannel <- &chainntnfs.SpendDetail{SpendingTx: tx} + + confReg := d.confRegistration() + require.NotNil(d.t, confReg.TxID) + require.Equal(d.t, tx.TxHash(), *confReg.TxID) + + return confReg +} + +// confirm delivers the confirmation of the htlc tx and waits for the deposits +// to be locked for the timeout sweep. +func (d *decoyMonitorTest) confirm(reg *test.ConfRegistration, + tx *wire.MsgTx) { + + reg.ConfChan <- &chainntnfs.TxConfirmation{Tx: tx} + + select { + case transition := <-d.depositMgr.transitionChan: + require.Equal( + d.t, deposit.SweepHtlcTimeout, transition.state, + ) + + case <-d.ctx.Done(): + d.t.Fatalf("deposits were not locked after the htlc "+ + "confirmation: %v", d.ctx.Err()) + } +} + +// expire notifies a block at which the htlc has timed out. +func (d *decoyMonitorTest) expire() { + height := max(d.mockLnd.Height, d.f.loopIn.HtlcCltvExpiry) + 1 + require.NoError(d.t, d.mockLnd.NotifyHeight(height)) +} + +// requireWaiting checks that the monitor keeps running and didn't touch the +// deposits. +func (d *decoyMonitorTest) requireWaiting() { + select { + case event := <-d.resultChan: + d.t.Fatalf("monitor exited with %v: %v", event, + d.f.LastActionError) + + case transition := <-d.depositMgr.transitionChan: + d.t.Fatalf("deposits moved to %v", transition.state) + + case <-time.After(200 * time.Millisecond): + } +} + +// result waits for the monitor to exit. +func (d *decoyMonitorTest) result() fsm.EventType { + select { + case event := <-d.resultChan: + return event + + case <-d.ctx.Done(): + d.t.Fatalf("monitor did not exit: %v", d.ctx.Err()) + } + + return fsm.NoOp +} + +// requireTimeoutSweep expires the htlc and checks that the swap sweeps it and +// that the deposits were never released. +func (d *decoyMonitorTest) requireTimeoutSweep() { + d.expire() + require.Equal(d.t, OnSweepHtlcTimeout, d.result()) + require.NotContains(d.t, d.depositMgr.states, deposit.Deposited) +} + +// requireTimedOut expires the htlc and checks that the swap times out and +// releases the deposits. +func (d *decoyMonitorTest) requireTimedOut() { + d.expire() + require.Equal(d.t, OnSwapTimedOut, d.result()) + require.Equal( + d.t, []fsm.StateType{deposit.Deposited}, d.depositMgr.states, + ) +} + +// TestMonitorHtlcTimeoutWaitsForSpentDeposits verifies that the htlc timeout +// doesn't release deposits the htlc tx already spent. After a decoy the htlc +// tx is found from the deposit spend, and its confirmation can arrive after +// the htlc expiry. +func TestMonitorHtlcTimeoutWaitsForSpentDeposits(t *testing.T) { + // The spend watch reports the htlc tx before the htlc expires, and + // lnd's wallet isn't asked. + t.Run("spend seen by the monitor", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.f.cfg.TxOutChecker = nil + htlcTx := d.htlcTx(100_000) + + spendReg := d.decoy(d.start()) + confReg := d.spend(spendReg, htlcTx) + + d.expire() + d.requireWaiting() + + d.confirm(confReg, htlcTx) + d.requireTimeoutSweep() + }) + + // The monitor starts after the htlc expiry, so the block arrives + // before the deposit spend. lnd's wallet lists the htlc tx. + t.Run("start after htlc expiry", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.f.loopIn.HtlcCltvExpiry = d.mockLnd.Height - 10 + d.txOuts.htlcSpent = true + htlcTx := d.htlcTx(100_000) + + confReg := d.start() + d.requireWaiting() + + spendReg := d.decoy(confReg) + confReg = d.spend(spendReg, htlcTx) + d.requireWaiting() + + d.confirm(confReg, htlcTx) + d.requireTimeoutSweep() + + // The lookup asked about the signed deposits and the htlc. + htlc, err := d.f.loopIn.getHtlc(d.f.cfg.ChainParams) + require.NoError(t, err) + signedOutpoints, err := d.f.depositOutpoints() + require.NoError(t, err) + require.NotEmpty(t, d.txOuts.spendOutpoints) + require.Equal(t, signedOutpoints, d.txOuts.spendOutpoints[0]) + require.Equal(t, htlc.PkScript, d.txOuts.spendPkScripts[0]) + }) + + // lnd's wallet lists the htlc tx at the htlc expiry and no longer + // lists it at the next block. The swap asks again and stops waiting. + t.Run("htlc tx gone at a later block", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.f.loopIn.HtlcCltvExpiry = d.mockLnd.Height - 10 + d.txOuts.htlcSpentResults = []bool{true} + + d.start() + d.requireWaiting() + + d.requireTimedOut() + require.Len(t, d.txOuts.spendOutpoints, 2) + }) + + // A swap without an outpoint snapshot asks about its deposit rows. + t.Run("no outpoint snapshot", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.f.loopIn.DepositOutpoints = nil + d.f.loopIn.HtlcCltvExpiry = d.mockLnd.Height - 10 + d.txOuts.htlcSpentResults = []bool{true} + + d.start() + d.requireWaiting() + + // Let the monitor exit before reading what it asked about. + d.requireTimedOut() + require.NotEmpty(t, d.txOuts.spendOutpoints) + require.Equal( + t, d.f.loopIn.Outpoints(), d.txOuts.spendOutpoints[0], + ) + }) + + // A failed lookup must not release the deposits. + t.Run("lookup error", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.f.loopIn.HtlcCltvExpiry = d.mockLnd.Height - 10 + d.txOuts.err = errors.New("lnd unavailable") + + d.start() + require.Equal(t, OnRecover, d.result()) + require.Empty(t, d.depositMgr.states) + }) + + // The htlc tx didn't spend the deposits, so they are released. + t.Run("deposits not spent", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + + d.decoy(d.start()) + d.requireTimedOut() + require.NotEmpty(t, d.txOuts.spendOutpoints) + }) +} + +// TestMonitorDropsHtlcTxOnReorg verifies that a reorg drops the htlc tx found +// from the deposit spend. The deposits can then be spent by another signed +// fee variant of the htlc tx, or not at all. +func TestMonitorDropsHtlcTxOnReorg(t *testing.T) { + // The spend is reorged out and another fee variant of the htlc tx + // spends the deposits. + t.Run("spend reorg, other fee variant", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + variantA, variantB := d.htlcTx(100_000), d.htlcTx(99_000) + require.NotEqual(t, variantA.TxHash(), variantB.TxHash()) + + spendReg := d.decoy(d.start()) + confReg := d.spend(spendReg, variantA) + + // The reorg starts a new spend watch for the same deposit. + spendReg.ReorgChan <- struct{}{} + spendReg2 := d.spendRegistration() + require.Equal(t, *spendReg.Outpoint, *spendReg2.Outpoint) + require.Equal(t, spendReg.PkScript, spendReg2.PkScript) + + // The txid of variant A is dropped, so a confirmation + // subscription that fails now registers by script again. + confReg.ErrChan <- errors.New("lnd restarting") + confReg = d.confRegistration() + require.Nil(t, confReg.TxID) + + confReg = d.spend(spendReg2, variantB) + d.confirm(confReg, variantB) + d.requireTimeoutSweep() + }) + + // The spend is reorged out and the same htlc tx confirms again after + // the htlc expiry. lnd's wallet still lists it, so the deposits stay + // locked. + t.Run("spend reorg, htlc tx confirms again", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.txOuts.htlcSpent = true + htlcTx := d.htlcTx(100_000) + + spendReg := d.decoy(d.start()) + d.spend(spendReg, htlcTx) + + spendReg.ReorgChan <- struct{}{} + spendReg = d.spendRegistration() + + d.expire() + d.requireWaiting() + + confReg := d.spend(spendReg, htlcTx) + d.confirm(confReg, htlcTx) + d.requireTimeoutSweep() + require.NotEmpty(t, d.txOuts.spendOutpoints) + }) + + // The spend is reorged out and the htlc tx is gone for good. The swap + // must not wait for it forever. + t.Run("spend reorg, htlc tx gone", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + + spendReg := d.decoy(d.start()) + d.spend(spendReg, d.htlcTx(100_000)) + + spendReg.ReorgChan <- struct{}{} + d.spendRegistration() + + d.requireTimedOut() + require.NotEmpty(t, d.txOuts.spendOutpoints) + }) + + // The spend is reorged out after the htlc tx confirmed. The + // confirmation no longer counts. + t.Run("spend reorg after confirmation", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + htlcTx := d.htlcTx(100_000) + + // With an open invoice the confirmation doesn't lock the + // deposits yet. + d.mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: d.f.loopIn.SwapHash, + State: invoices.ContractOpen, + }) + + spendReg := d.decoy(d.start()) + confReg := d.spend(spendReg, htlcTx) + confReg.ConfChan <- &chainntnfs.TxConfirmation{Tx: htlcTx} + + // The monitor must take the confirmation before the reorg. + require.Eventually(t, func() bool { + return len(confReg.ConfChan) == 0 + }, testTimeout, 10*time.Millisecond) + d.requireWaiting() + + spendReg.ReorgChan <- struct{}{} + d.spendRegistration() + + d.requireTimedOut() + }) + + // A replaced spend registration has its own reorg channel, so a + // reorg it reports later is not handled a second time. + t.Run("reorg on a replaced registration", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + + spendReg := d.decoy(d.start()) + d.spend(spendReg, d.htlcTx(100_000)) + + spendReg.ReorgChan <- struct{}{} + d.spendRegistration() + + spendReg.ReorgChan <- struct{}{} + select { + case <-d.mockLnd.RegisterSpendChannel: + t.Fatal("reorg of a replaced registration was handled") + + case <-time.After(200 * time.Millisecond): + } + + d.requireTimedOut() + }) + + // A reorg reported by the confirmation subscription drops the txid + // too, so the confirmation is registered by script again. + t.Run("confirmation reorg", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + + spendReg := d.decoy(d.start()) + confReg := d.spend(spendReg, d.htlcTx(100_000)) + + require.NotNil(t, confReg.ReorgChan) + confReg.ReorgChan <- struct{}{} + confReg = d.confRegistration() + require.Nil(t, confReg.TxID) + + d.requireTimedOut() + }) +} + +// TestMonitorDepositSpendWatchErrors verifies that the monitor is retried if +// the deposit spend can't be watched. +func TestMonitorDepositSpendWatchErrors(t *testing.T) { + t.Run("subscription error", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + + spendReg := d.decoy(d.start()) + spendReg.ErrChan <- errors.New("lnd restarting") + + require.Equal(t, OnRecover, d.result()) + require.Empty(t, d.depositMgr.states) + }) + + t.Run("registration error", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + d.f.loopIn.AddressParams = nil + + confReg := d.start() + confReg.ConfChan <- &chainntnfs.TxConfirmation{ + Tx: wire.NewMsgTx(2), + } + + require.Equal(t, OnRecover, d.result()) + require.Empty(t, d.depositMgr.states) + }) +} + +// TestMonitorWatchesSignedDepositOutpoints verifies that the monitor takes the +// htlc tx inputs from the outpoints the htlc tx was signed for. The deposit +// rows of a recovered swap can differ from them. +func TestMonitorWatchesSignedDepositOutpoints(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + d := newDecoyMonitorTest(t, ctx) + + // The swap was signed for two deposits, but only the second one is + // still mapped to it. + first := wire.OutPoint{Hash: chainhash.Hash{6}, Index: 3} + second := d.f.loopIn.Deposits[0].OutPoint + d.f.loopIn.DepositOutpoints = []string{ + first.String(), second.String(), + } + + htlcTx := d.htlcTx(100_000) + require.Len(t, htlcTx.TxIn, 2) + + spendReg := d.decoy(d.start()) + require.Equal(t, first, *spendReg.Outpoint) + + confReg := d.spend(spendReg, htlcTx) + d.confirm(confReg, htlcTx) + d.requireTimeoutSweep() +} + +// TestIsHtlcTx verifies that only a tx that spends exactly the deposits of the +// swap and pays to the htlc is taken as the htlc tx. +func TestIsHtlcTx(t *testing.T) { + htlcPkScript := []byte{0x00, 0x20, 0x01} + deposit1 := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0} + deposit2 := wire.OutPoint{Hash: chainhash.Hash{2}, Index: 1} + foreign := wire.OutPoint{Hash: chainhash.Hash{3}, Index: 0} + + f := &FSM{ + loopIn: &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{ + {OutPoint: deposit1}, + {OutPoint: deposit2}, + }, + }, + } + + newTx := func(pkScript []byte, inputs ...wire.OutPoint) *wire.MsgTx { + tx := wire.NewMsgTx(2) + for _, input := range inputs { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: input}) + } + tx.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: pkScript}) + + return tx + } + + tests := []struct { + name string + tx *wire.MsgTx + want bool + }{ + { + name: "htlc tx", + tx: newTx(htlcPkScript, deposit1, deposit2), + want: true, + }, + { + name: "deposits in other order", + tx: newTx(htlcPkScript, deposit2, deposit1), + want: true, + }, + { + name: "nil tx", + }, + { + name: "foreign input only", + tx: newTx(htlcPkScript, foreign), + }, + { + name: "one deposit missing", + tx: newTx(htlcPkScript, deposit1), + }, + { + name: "foreign input instead of a deposit", + tx: newTx(htlcPkScript, deposit1, foreign), + }, + { + name: "extra foreign input", + tx: newTx(htlcPkScript, deposit1, deposit2, foreign), + }, + { + name: "same deposit twice", + tx: newTx(htlcPkScript, deposit1, deposit1), + }, + { + name: "no htlc output", + tx: newTx([]byte{0x51}, deposit1, deposit2), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := f.isHtlcTx(tc.tx, htlcPkScript) + require.Equal(t, tc.want, got) + }) + } + + // The htlc tx is signed for the outpoint snapshot. The deposit rows + // of a recovered swap can differ from it and must not be used then. + t.Run("snapshot differs from deposit rows", func(t *testing.T) { + f := &FSM{ + loopIn: &StaticAddressLoopIn{ + DepositOutpoints: []string{ + deposit1.String(), deposit2.String(), + }, + Deposits: []*deposit.Deposit{ + {OutPoint: deposit1}, + }, + }, + } + + require.True(t, f.isHtlcTx( + newTx(htlcPkScript, deposit1, deposit2), htlcPkScript, + )) + require.False(t, f.isHtlcTx( + newTx(htlcPkScript, deposit1), htlcPkScript, + )) + }) +} + // failingCancelInvoices records cancellation attempts and returns a configured // error after its release channel is closed. type failingCancelInvoices struct { @@ -3858,6 +4751,10 @@ type recordingTxOutChecker struct { // htlcSpent is what SpentByTxPayingTo returns. htlcSpent bool + // htlcSpentResults, if set, are returned by SpentByTxPayingTo one + // per call before it falls back to htlcSpent. + htlcSpentResults []bool + // spendOutpoints, spendPkScripts and spendStartHeights record what // SpentByTxPayingTo was asked about. spendOutpoints [][]wire.OutPoint @@ -3894,6 +4791,13 @@ func (r *recordingTxOutChecker) SpentByTxPayingTo(_ context.Context, return false, r.err } + if len(r.htlcSpentResults) > 0 { + spent := r.htlcSpentResults[0] + r.htlcSpentResults = r.htlcSpentResults[1:] + + return spent, nil + } + return r.htlcSpent, nil } @@ -3997,6 +4901,8 @@ func TestMonitorInvoiceAndHtlcTxSweepsTimeoutAfterLegacyRefresh(t *testing.T) { liveDeposit.ConfirmationHeight = confirmationHeight f.loopIn.DepositOutpoints = []string{liveDeposit.OutPoint.String()} + htlcConf := htlcConfirmation(t, f) + reloaded := make(chan struct{}, 1) depositMgr.depositsForOutpoints = func([]string, bool) { select { @@ -4035,7 +4941,7 @@ func TestMonitorInvoiceAndHtlcTxSweepsTimeoutAfterLegacyRefresh(t *testing.T) { // The HTLC confirms and the payment deadline passes without payment, // which locks the deposits for the timeout sweep. - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- htlcConf select { case transition := <-depositMgr.transitionChan: diff --git a/staticaddr/loopin/risk_rejection_test.go b/staticaddr/loopin/risk_rejection_test.go index 391c74694..3c11411fc 100644 --- a/staticaddr/loopin/risk_rejection_test.go +++ b/staticaddr/loopin/risk_rejection_test.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" @@ -29,6 +30,7 @@ type riskRejectionTest struct { depositMgr *recordingDepositManager riskMgr *mockNotificationManager txOuts *recordingTxOutChecker + htlcConf *chainntnfs.TxConfirmation } // invoicesFor returns the invoices client the monitor uses. @@ -172,6 +174,8 @@ func (r *riskRejectionTest) result(resultChan chan fsm.EventType) fsm.EventType func (r *riskRejectionTest) start(ctx context.Context) (chan fsm.EventType, *test.ConfRegistration) { + r.htlcConf = htlcConfirmation(r.t, r.f) + resultChan := make(chan fsm.EventType, 1) go func() { resultChan <- r.f.MonitorInvoiceAndHtlcTxAction(ctx, nil) @@ -199,8 +203,8 @@ func (r *riskRejectionTest) start(ctx context.Context) (chan fsm.EventType, // confirmHtlc delivers the htlc confirmation. The second send only fits the // buffer once the monitor received the first one. func (r *riskRejectionTest) confirmHtlc(reg *test.ConfRegistration) { - reg.ConfChan <- nil - reg.ConfChan <- nil + reg.ConfChan <- r.htlcConf + reg.ConfChan <- r.htlcConf } func (r *riskRejectionTest) reject() { diff --git a/test/chainnotifier_mock.go b/test/chainnotifier_mock.go index 1da5f87d4..8dfb546df 100644 --- a/test/chainnotifier_mock.go +++ b/test/chainnotifier_mock.go @@ -38,6 +38,11 @@ type SpendRegistration struct { HeightHint int32 SpendChannel chan<- *chainntnfs.SpendDetail ErrChan chan<- error + + // ReorgChan is the channel passed with lndclient.WithReOrgChan, or + // nil. The mock forwards one event only, so a test sends the reorg + // on this channel itself. + ReorgChan chan<- struct{} } // ConfRegistration contains registration details. @@ -48,11 +53,25 @@ type ConfRegistration struct { NumConfs int32 ConfChan chan *chainntnfs.TxConfirmation ErrChan chan<- error + + // ReorgChan is the channel passed with lndclient.WithReOrgChan, or + // nil. A test sends the reorg on this channel itself. + ReorgChan chan<- struct{} +} + +// reorgChan returns the reorg channel set by the notifier options. +func reorgChan(opts []lndclient.NotifierOption) chan<- struct{} { + options := lndclient.DefaultNotifierOptions() + for _, opt := range opts { + opt(options) + } + + return options.ReOrgChan } func (c *mockChainNotifier) RegisterSpendNtfn(ctx context.Context, outpoint *wire.OutPoint, pkScript []byte, heightHint int32, - _ ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail, + opts ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail, chan error, error) { spendChan0 := make(chan *chainntnfs.SpendDetail) @@ -64,6 +83,7 @@ func (c *mockChainNotifier) RegisterSpendNtfn(ctx context.Context, PkScript: pkScript, SpendChannel: spendChan0, ErrChan: spendErrChan, + ReorgChan: reorgChan(opts), } c.lnd.RegisterSpendChannel <- reg @@ -162,6 +182,7 @@ func (c *mockChainNotifier) RegisterConfirmationsNtfn(ctx context.Context, NumConfs: numConfs, ConfChan: make(chan *chainntnfs.TxConfirmation, 1), ErrChan: confErrChan, + ReorgChan: reorgChan(opts), } c.Lock() From 686ef379b0d6ca03ea6924f8e701209a00025c5c Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Thu, 12 Nov 2026 11:40:20 +0200 Subject: [PATCH 2/2] docs: add release note for the htlc confirmation check Note that a static address loop-in only takes a confirmation of the htlc tx that spent its deposits. --- docs/release-notes/release-notes-next.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 8ea78eedd..9f4069363 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -14,6 +14,13 @@ A static Loop In also no longer locks a deposit that another Loop In is already using, and a failed lock no longer resets deposits that a withdrawal or a channel open has taken. +* A static address loop-in no longer takes a confirmed transaction that pays to + its htlc as the htlc transaction unless that transaction also spends the + swap's deposits. Before, the server could confirm a dust output to the htlc + script and lock the deposits in a timeout sweep that could never succeed. + The htlc transaction is then found from the spend of the deposits, and at + the htlc expiry the deposits are only released if lnd's wallet lists no + htlc transaction that spent them. #### Maintenance