From 4c5691b05e5755311e653a2c2a71451106b2f4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?v=20=E2=82=BF?= Date: Wed, 9 Sep 2026 21:26:29 +0300 Subject: [PATCH 1/2] sweep: preserve valid inputs after missing-input race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: v ₿ --- docs/release-notes/release-notes-0.22.0.md | 2 + server.go | 29 +++++- sweep/fee_bumper.go | 110 ++++++++++++++++----- sweep/fee_bumper_test.go | 51 ++++++++++ sweep/sweeper.go | 11 +++ sweep/sweeper_test.go | 52 ++++++++++ 6 files changed, 229 insertions(+), 26 deletions(-) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index de0c108ec11..b0a43fdf79f 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -22,6 +22,8 @@ # Bug Fixes +* Fixed a [sweeper missing-input race](https://github.com/lightningnetwork/lnd/issues/10225) where a delayed historical spend lookup could cause valid inputs batched with a missing input to be marked fatal instead of retried. + * Bitcoind outbound peer health checks [now use](https://github.com/lightningnetwork/lnd/pull/10686) `getnetworkinfo.connections_out` instead of `getpeerinfo`. The same PR also [clarifies](https://github.com/lightningnetwork/lnd/issues/10568) the ZMQ diff --git a/server.go b/server.go index 184f322f307..8a4b3274d39 100644 --- a/server.go +++ b/server.go @@ -62,6 +62,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" chcl "github.com/lightningnetwork/lnd/lnwallet/chancloser" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" @@ -1301,10 +1302,30 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, ) s.txPublisher = sweep.NewTxPublisher(sweep.TxPublisherConfig{ - Signer: cc.Wallet.Cfg.Signer, - Wallet: cc.Wallet, - Estimator: cc.FeeEstimator, - Notifier: cc.ChainNotifier, + Signer: cc.Wallet.Cfg.Signer, + Wallet: cc.Wallet, + Estimator: cc.FeeEstimator, + Notifier: cc.ChainNotifier, + IsInputUnspent: func(inp input.Input) (bool, error) { + op := inp.OutPoint() + _, err := cc.ChainIO.GetUtxo( + &op, inp.SignDesc().Output.PkScript, + inp.HeightHint(), s.quit, + ) + + switch { + case err == nil: + return true, nil + + case errors.Is(err, btcwallet.ErrOutputSpent), + errors.Is(err, btcwallet.ErrOutputNotFound): + + return false, nil + + default: + return false, err + } + }, AuxSweeper: s.implCfg.AuxSweeper, }) diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index 6088094ffe8..ef7bc995413 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -281,6 +281,11 @@ type BumpResult struct { // current tx to be failed. SpentInputs map[wire.OutPoint]*wire.MsgTx + // MissingInputs are inputs that a blocking UTXO lookup confirmed no + // longer exist. Unlike SpentInputs, the spending transaction may not be + // known. + MissingInputs map[wire.OutPoint]struct{} + // requestID is the ID of the request that created this record. requestID uint64 } @@ -344,6 +349,12 @@ type TxPublisherConfig struct { // Notifier is used to monitor the confirmation status of the tx. Notifier chainntnfs.ChainNotifier + // IsInputUnspent performs a blocking chain lookup for an input. It is + // used only after the backend reports missing inputs, when an + // asynchronous spend notification may still be waiting on a historical + // rescan. + IsInputUnspent func(input.Input) (bool, error) + // AuxSweeper is an optional interface that can be used to modify the // way sweep transaction are generated. AuxSweeper fn.Option[AuxSweeper] @@ -677,36 +688,91 @@ func (t *TxPublisher) createAndCheckTx(r *monitorRecord) (*sweepTxCtx, error) { sweepCtx.tx.TxHash(), err) } +// findMissingInputs uses a blocking chain lookup to identify which inputs no +// longer exist. This is used only after testmempoolaccept has already returned +// a missing-input error. +func (t *TxPublisher) findMissingInputs( + inputs []input.Input) (map[wire.OutPoint]struct{}, error) { + + missing := make(map[wire.OutPoint]struct{}) + for _, inp := range inputs { + unspent, err := t.cfg.IsInputUnspent(inp) + if err != nil { + return nil, err + } + + if !unspent { + missing[inp.OutPoint()] = struct{}{} + } + } + + return missing, nil +} + +// createMissingInputRetryResult creates a retryable result for an ambiguous +// missing-input error. This protects the full input set from being marked +// fatal when the chain lookup cannot identify a specific missing input. +func (t *TxPublisher) createMissingInputRetryResult( + r *monitorRecord, err error) *BumpResult { + + result := &BumpResult{ + Event: TxFailed, + Tx: r.tx, + requestID: r.requestID, + Err: err, + } + + feeRate, feeErr := t.calculateRetryFeeRate(r) + if feeErr != nil { + result.Event = TxFatal + result.Err = feeErr + } + + result.FeeRate = feeRate + + return result +} + // handleMissingInputs handles the case when the chain backend reports back a -// missing inputs error, which could happen when one of the input has been spent -// in another tx, or the input is referencing an orphan. When the input is -// spent, it will be handled via the TxUnknownSpend flow by creating a -// TxUnknownSpend bump result, otherwise, a TxFatal bump result is returned. +// missing inputs error. Spend notifications are checked first, then a blocking +// UTXO lookup is used to distinguish specific missing inputs from a historical +// notification race. func (t *TxPublisher) handleMissingInputs(r *monitorRecord) *BumpResult { - // Get the spending txns. + // Get any spending txns that have already reached the notifier. spends := t.getSpentInputs(r) - - // Attach the spending txns. r.spentInputs = spends - // If there are no spending txns found and the input is missing, the - // input is referencing an orphan tx that's no longer valid, e.g., the - // spending the anchor output from the remote commitment after the local - // commitment has confirmed. In this case we will mark it as fatal and - // exit. if len(spends) == 0 { - log.Warnf("Failing record=%v: found orphan inputs: %v\n", - r.requestID, inputTypeSummary(r.req.Inputs)) - - // Create a result that will be sent to the resultChan which is - // listened by the caller. - result := &BumpResult{ - Event: TxFatal, - Tx: r.tx, - requestID: r.requestID, - Err: ErrInputMissing, + // Preserve the old behavior for callers that don't provide the + // blocking lookup callback. Production wiring always provides it. + if t.cfg.IsInputUnspent == nil { + return &BumpResult{ + Event: TxFatal, + Tx: r.tx, + requestID: r.requestID, + Err: ErrInputMissing, + } } + missing, err := t.findMissingInputs(r.req.Inputs) + if err != nil { + return t.createMissingInputRetryResult( + r, fmt.Errorf("verify missing inputs: %w", err), + ) + } + + // If every input is still unspent, the backend and historical + // lookup disagree. Retry instead of permanently dropping the set. + if len(missing) == 0 { + return t.createMissingInputRetryResult(r, ErrInputMissing) + } + + // Reuse the partial unknown-spend flow so only the confirmed + // missing inputs are removed and the remaining inputs are retried. + result := t.createUnknownSpentBumpResult(r) + result.Err = ErrInputMissing + result.MissingInputs = missing + return result } diff --git a/sweep/fee_bumper_test.go b/sweep/fee_bumper_test.go index c2653d795c2..c93e0894ccf 100644 --- a/sweep/fee_bumper_test.go +++ b/sweep/fee_bumper_test.go @@ -2302,3 +2302,54 @@ func createTestInputWithLocktime(value int64, witnessType input.WitnessType, return inp } + +// TestHandleMissingInputsHistoricalLookup checks that a delayed historical +// spend notification cannot cause a mixed input set to be marked fatal. +func TestHandleMissingInputsHistoricalLookup(t *testing.T) { + t.Parallel() + + missingInput := createTestInput(10_000, input.WitnessKeyHash) + validInput := createTestInput(10_000, input.WitnessKeyHash) + missingOp := missingInput.OutPoint() + validOp := validInput.OutPoint() + + notifier := &chainntnfs.MockChainNotifier{} + defer notifier.AssertExpectations(t) + + // Simulate historical spend registrations whose asynchronous result has + // not arrived yet. This is the race from #10225. + notifier.On( + "RegisterSpendNtfn", mock.Anything, mock.Anything, uint32(1), + ).Return(chainntnfs.NewSpendEvent(func() {}), nil).Twice() + + feeRate := chainfee.SatPerKWeight(1_000) + feeFunc := &MockFeeFunction{} + defer feeFunc.AssertExpectations(t) + feeFunc.On("FeeRate").Return(feeRate).Once() + feeFunc.On("Increment").Return(true, nil).Once() + + tp := NewTxPublisher(TxPublisherConfig{ + Notifier: notifier, + IsInputUnspent: func(inp input.Input) (bool, error) { + return inp.OutPoint() != missingOp, nil + }, + }) + + record := &monitorRecord{ + requestID: 1, + tx: &wire.MsgTx{}, + req: &BumpRequest{ + Inputs: []input.Input{&missingInput, &validInput}, + }, + feeFunction: feeFunc, + } + + result := tp.handleMissingInputs(record) + + require.Equal(t, TxUnknownSpend, result.Event) + require.ErrorIs(t, result.Err, ErrInputMissing) + require.Equal(t, feeRate, result.FeeRate) + require.Contains(t, result.MissingInputs, missingOp) + require.NotContains(t, result.MissingInputs, validOp) + require.Len(t, result.MissingInputs, 1) +} diff --git a/sweep/sweeper.go b/sweep/sweeper.go index 547a92c47fb..5987b267cb5 100644 --- a/sweep/sweeper.go +++ b/sweep/sweeper.go @@ -1994,6 +1994,17 @@ func (s *UtxoSweeper) handleBumpEventTxUnknownSpend(r *bumpResp) { continue } + // A missing input may not have a known spending transaction if + // it references an orphaned output. Fail only that input and + // leave the rest of the set retryable. + if _, missing := r.result.MissingInputs[op]; missing { + if !input.terminated() { + s.markInputFatal(input, nil, ErrInputMissing) + } + + continue + } + // Check whether this input has been spent, if so we mark it as // fatal or swept based on whether this is one of our previous // sweeping txns, then move to the next. diff --git a/sweep/sweeper_test.go b/sweep/sweeper_test.go index 0d22a6dd7b0..d0d3474fab9 100644 --- a/sweep/sweeper_test.go +++ b/sweep/sweeper_test.go @@ -1445,3 +1445,55 @@ func TestHandleBumpEventTxUnknownSpendWithRetry(t *testing.T) { // Assert the state of the input is updated. require.Equal(t, PublishFailed, s.inputs[op2].state) } + +// TestHandleBumpEventTxUnknownSpendMissingInput checks that a specifically +// identified missing input is failed without preventing the rest of the set +// from being retried. +func TestHandleBumpEventTxUnknownSpendMissingInput(t *testing.T) { + t.Parallel() + + aggregator := &mockUtxoAggregator{} + defer aggregator.AssertExpectations(t) + + s := New(&UtxoSweeperConfig{ + Aggregator: aggregator, + }) + set := &MockInputSet{} + defer set.AssertExpectations(t) + + missingInput := createMockInput(t, s, Published) + validInput := createMockInput(t, s, Published) + inputs := []input.Input{missingInput, validInput} + set.On("Inputs").Return(inputs) + + validInput.On("RequiredLockTime").Return( + uint32(s.currentHeight), false).Once() + validInput.On("BlocksToMaturity").Return(uint32(0)).Once() + validInput.On("HeightHint").Return(uint32(s.currentHeight)).Once() + + aggregator.On("ClusterInputs", mock.Anything).Return([]InputSet{}) + + feeRate := chainfee.SatPerKWeight(1_000) + resp := &bumpResp{ + set: set, + result: &BumpResult{ + Event: TxUnknownSpend, + FeeRate: feeRate, + Err: ErrInputMissing, + MissingInputs: map[wire.OutPoint]struct{}{ + missingInput.OutPoint(): {}, + }, + }, + } + + s.handleBumpEventTxUnknownSpend(resp) + + require.NotContains(t, s.inputs, missingInput.OutPoint()) + + valid := s.inputs[validInput.OutPoint()] + require.Equal(t, PublishFailed, valid.state) + require.True(t, valid.params.Immediate) + require.Equal( + t, feeRate, valid.params.StartingFeeRate.UnsafeFromSome(), + ) +} From 522cd6829e30cdc695b7e0ecdb320a631e0c8502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?v=20=E2=82=BF?= Date: Sat, 19 Sep 2026 06:15:20 +0300 Subject: [PATCH 2/2] sweep: harden missing-input classification --- server.go | 31 ++- server_input_lookup_test.go | 58 +++++ sweep/fee_bumper.go | 87 ++++++- sweep/fee_bumper_missing_inputs_test.go | 292 ++++++++++++++++++++++++ 4 files changed, 444 insertions(+), 24 deletions(-) create mode 100644 server_input_lookup_test.go create mode 100644 sweep/fee_bumper_missing_inputs_test.go diff --git a/server.go b/server.go index 8a4b3274d39..582a7c19cd6 100644 --- a/server.go +++ b/server.go @@ -662,6 +662,23 @@ func noiseDial(idKey keychain.SingleKeyECDH, // newServer creates a new instance of the server which is to listen using the // passed listener address. // +// classifyInputUtxoLookup maps the blocking UTXO lookup result into the +// missing-input classifier used by the transaction publisher. +func classifyInputUtxoLookup(err error) (bool, error) { + switch { + case err == nil: + return true, nil + + case errors.Is(err, btcwallet.ErrOutputSpent), + errors.Is(err, btcwallet.ErrOutputNotFound): + + return false, nil + + default: + return false, err + } +} + //nolint:funlen func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, dbs *DatabaseInstances, cc *chainreg.ChainControl, @@ -1306,6 +1323,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, Wallet: cc.Wallet, Estimator: cc.FeeEstimator, Notifier: cc.ChainNotifier, + Mempool: cc.MempoolNotifier, IsInputUnspent: func(inp input.Input) (bool, error) { op := inp.OutPoint() _, err := cc.ChainIO.GetUtxo( @@ -1313,18 +1331,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, inp.HeightHint(), s.quit, ) - switch { - case err == nil: - return true, nil - - case errors.Is(err, btcwallet.ErrOutputSpent), - errors.Is(err, btcwallet.ErrOutputNotFound): - - return false, nil - - default: - return false, err - } + return classifyInputUtxoLookup(err) }, AuxSweeper: s.implCfg.AuxSweeper, }) diff --git a/server_input_lookup_test.go b/server_input_lookup_test.go new file mode 100644 index 00000000000..69b66b77ef4 --- /dev/null +++ b/server_input_lookup_test.go @@ -0,0 +1,58 @@ +package lnd + +import ( + "errors" + "testing" + + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" + "github.com/stretchr/testify/require" +) + +// TestClassifyInputUtxoLookup verifies the production error mapping used by +// the missing-input fallback. +func TestClassifyInputUtxoLookup(t *testing.T) { + t.Parallel() + + lookupErr := errors.New("lookup failed") + tests := []struct { + name string + err error + wantUnspent bool + wantErr error + }{ + { + name: "unspent", + wantUnspent: true, + }, + { + name: "spent", + err: btcwallet.ErrOutputSpent, + wantErr: nil, + }, + { + name: "not found", + err: btcwallet.ErrOutputNotFound, + wantErr: nil, + }, + { + name: "backend error", + err: lookupErr, + wantErr: lookupErr, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + unspent, err := classifyInputUtxoLookup(testCase.err) + require.Equal(t, testCase.wantUnspent, unspent) + if testCase.wantErr == nil { + require.NoError(t, err) + return + } + + require.ErrorIs(t, err, testCase.wantErr) + }) + } +} diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index ef7bc995413..c94ddbc68ac 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -281,9 +281,9 @@ type BumpResult struct { // current tx to be failed. SpentInputs map[wire.OutPoint]*wire.MsgTx - // MissingInputs are inputs that a blocking UTXO lookup confirmed no - // longer exist. Unlike SpentInputs, the spending transaction may not be - // known. + // MissingInputs are inputs that the blocking fallback could not locate + // as current UTXOs or as outputs of wallet-known unconfirmed parents. + // Unlike SpentInputs, the spending transaction may not be known. MissingInputs map[wire.OutPoint]struct{} // requestID is the ID of the request that created this record. @@ -349,6 +349,10 @@ type TxPublisherConfig struct { // Notifier is used to monitor the confirmation status of the tx. Notifier chainntnfs.ChainNotifier + // Mempool is used as a synchronous fallback when a spend notification + // has not reached the notifier yet. + Mempool chainntnfs.MempoolWatcher + // IsInputUnspent performs a blocking chain lookup for an input. It is // used only after the backend reports missing inputs, when an // asynchronous spend notification may still be waiting on a historical @@ -688,9 +692,10 @@ func (t *TxPublisher) createAndCheckTx(r *monitorRecord) (*sweepTxCtx, error) { sweepCtx.tx.TxHash(), err) } -// findMissingInputs uses a blocking chain lookup to identify which inputs no -// longer exist. This is used only after testmempoolaccept has already returned -// a missing-input error. +// findMissingInputs uses blocking fallbacks to identify inputs that can no +// longer be located. This only runs after testmempoolaccept reports missing +// inputs and the asynchronous spend subscriptions have not identified a +// spender, so the synchronous lookups are kept off the normal publish path. func (t *TxPublisher) findMissingInputs( inputs []input.Input) (map[wire.OutPoint]struct{}, error) { @@ -700,10 +705,38 @@ func (t *TxPublisher) findMissingInputs( if err != nil { return nil, err } + if unspent { + continue + } + + op := inp.OutPoint() + + // A chain lookup can report an output as absent while its + // parent is still unconfirmed because the backend excludes + // mempool outputs. If the wallet knows the parent, verify that + // it is still unconfirmed before keeping the input retryable. + if t.cfg.Wallet != nil { + parent, err := t.cfg.Wallet.FetchTx(op.Hash) + if err != nil { + return nil, err + } + if parent != nil && int(op.Index) < len(parent.TxOut) { + wallet := t.cfg.Wallet + details, err := wallet.GetTransactionDetails( + &op.Hash, + ) + if err != nil { + return nil, err + } + if details != nil && + details.NumConfirmations == 0 { - if !unspent { - missing[inp.OutPoint()] = struct{}{} + continue + } + } } + + missing[op] = struct{}{} } return missing, nil @@ -744,7 +777,8 @@ func (t *TxPublisher) handleMissingInputs(r *monitorRecord) *BumpResult { if len(spends) == 0 { // Preserve the old behavior for callers that don't provide the - // blocking lookup callback. Production wiring always provides it. + // blocking lookup callback. Production wiring always + // provides it. if t.cfg.IsInputUnspent == nil { return &BumpResult{ Event: TxFatal, @@ -762,13 +796,17 @@ func (t *TxPublisher) handleMissingInputs(r *monitorRecord) *BumpResult { } // If every input is still unspent, the backend and historical - // lookup disagree. Retry instead of permanently dropping the set. + // lookup disagree. Retry instead of permanently dropping + // the set. if len(missing) == 0 { - return t.createMissingInputRetryResult(r, ErrInputMissing) + return t.createMissingInputRetryResult( + r, ErrInputMissing, + ) } // Reuse the partial unknown-spend flow so only the confirmed - // missing inputs are removed and the remaining inputs are retried. + // missing inputs are removed and the remaining inputs are + // retried. result := t.createUnknownSpentBumpResult(r) result.Err = ErrInputMissing result.MissingInputs = missing @@ -1518,6 +1556,31 @@ func (t *TxPublisher) getSpentInputs( // Move to the next input. default: + // The historical notifier can lag behind the mempool. + // Query the watcher before falling back to UTXO + // classification so + // an in-mempool spender is handled as an unknown spend. + if t.cfg.Mempool == nil { + log.Tracef("Input %v not spent yet", op) + continue + } + + t.cfg.Mempool.LookupInputMempoolSpend(op).WhenSome( + func(spendingTx wire.MsgTx) { + tx := spendingTx + spentInputs[op] = &tx + }, + ) + if spendingTx, ok := spentInputs[op]; ok { + spendingTxID := spendingTx.TxHash() + log.Debugf( + "Detected mempool spend of input=%v "+ + "in tx=%v", op, spendingTxID, + ) + + continue + } + log.Tracef("Input %v not spent yet", op) } } diff --git a/sweep/fee_bumper_missing_inputs_test.go b/sweep/fee_bumper_missing_inputs_test.go new file mode 100644 index 00000000000..8fc7af75a05 --- /dev/null +++ b/sweep/fee_bumper_missing_inputs_test.go @@ -0,0 +1,292 @@ +package sweep + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func missingInputTestRecord(inputs ...input.Input) (*monitorRecord, + *MockFeeFunction) { + + feeRate := chainfee.SatPerKWeight(1_000) + feeFunc := &MockFeeFunction{} + feeFunc.On("FeeRate").Return(feeRate).Once() + feeFunc.On("Increment").Return(true, nil).Once() + + return &monitorRecord{ + requestID: 1, + tx: &wire.MsgTx{}, + req: &BumpRequest{ + Inputs: inputs, + }, + feeFunction: feeFunc, + }, feeFunc +} + +func emptySpendNotifier(t *testing.T, calls int) *chainntnfs.MockChainNotifier { + t.Helper() + + notifier := &chainntnfs.MockChainNotifier{} + notifier.On( + "RegisterSpendNtfn", mock.Anything, mock.Anything, uint32(1), + ).Return(chainntnfs.NewSpendEvent(func() {}), nil).Times(calls) + + t.Cleanup(func() { + notifier.AssertExpectations(t) + }) + + return notifier +} + +// TestHandleMissingInputsRetryBranches verifies that ambiguous lookup outcomes +// remain retryable instead of turning the whole batch fatal. +func TestHandleMissingInputsRetryBranches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lookup func(input.Input) (bool, error) + expected error + }{ + { + name: "all inputs remain unspent", + lookup: func(input.Input) (bool, error) { + return true, nil + }, + expected: ErrInputMissing, + }, + { + name: "blocking lookup fails", + lookup: func(input.Input) (bool, error) { + return false, errDummy + }, + expected: errDummy, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + record, feeFunc := missingInputTestRecord(&inp) + defer feeFunc.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Notifier: emptySpendNotifier(t, 1), + IsInputUnspent: testCase.lookup, + }) + + result := publisher.handleMissingInputs(record) + + require.Equal(t, TxFailed, result.Event) + require.ErrorIs(t, result.Err, testCase.expected) + require.Empty(t, result.MissingInputs) + }) + } +} + +// TestHandleMissingInputsNilLookup preserves the compatibility fallback for +// callers that do not provide the blocking lookup callback. +func TestHandleMissingInputsNilLookup(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + publisher := NewTxPublisher(TxPublisherConfig{ + Notifier: emptySpendNotifier(t, 1), + }) + record := &monitorRecord{ + requestID: 1, + tx: &wire.MsgTx{}, + req: &BumpRequest{ + Inputs: []input.Input{&inp}, + }, + } + + result := publisher.handleMissingInputs(record) + + require.Equal(t, TxFatal, result.Event) + require.ErrorIs(t, result.Err, ErrInputMissing) +} + +// TestHandleMissingInputsWalletParent verifies that a parent transaction known +// to the wallet keeps an unconfirmed output retryable even when the chain UTXO +// lookup excludes mempool outputs. +func TestHandleMissingInputsWalletParent(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + op := inp.OutPoint() + parent := wire.NewMsgTx(2) + parent.AddTxOut(&wire.TxOut{Value: 10_000}) + + wallet := &MockWallet{} + wallet.On("FetchTx", op.Hash).Return(parent, nil).Once() + wallet.On("GetTransactionDetails", mock.Anything).Return( + &lnwallet.TransactionDetail{NumConfirmations: 0}, nil, + ).Once() + defer wallet.AssertExpectations(t) + + record, feeFunc := missingInputTestRecord(&inp) + defer feeFunc.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Wallet: wallet, + Notifier: emptySpendNotifier(t, 1), + IsInputUnspent: func(input.Input) (bool, error) { + return false, nil + }, + }) + + result := publisher.handleMissingInputs(record) + + require.Equal(t, TxFailed, result.Event) + require.ErrorIs(t, result.Err, ErrInputMissing) + require.Empty(t, result.MissingInputs) +} + +// TestFindMissingInputsConfirmedWalletParent verifies that a wallet-known +// parent alone does not keep a confirmed, spent output retryable. +func TestFindMissingInputsConfirmedWalletParent(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + op := inp.OutPoint() + parent := wire.NewMsgTx(2) + parent.AddTxOut(&wire.TxOut{Value: 10_000}) + + wallet := &MockWallet{} + wallet.On("FetchTx", op.Hash).Return(parent, nil).Once() + wallet.On("GetTransactionDetails", mock.Anything).Return( + &lnwallet.TransactionDetail{NumConfirmations: 1}, nil, + ).Once() + defer wallet.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Wallet: wallet, + IsInputUnspent: func(input.Input) (bool, error) { + return false, nil + }, + }) + + missing, err := publisher.findMissingInputs([]input.Input{&inp}) + + require.NoError(t, err) + require.Contains(t, missing, op) +} + +// TestHandleMissingInputsMempoolSpend verifies that a spender already visible +// in the mempool is treated as an unknown spend even if the notifier has not +// delivered its historical spend notification yet. +func TestHandleMissingInputsMempoolSpend(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + op := inp.OutPoint() + spendingTx := wire.MsgTx{Version: 2} + + mempool := &chainntnfs.MockMempoolWatcher{} + mempool.On("LookupInputMempoolSpend", op). + Return(fn.Some(spendingTx)).Once() + defer mempool.AssertExpectations(t) + + record, feeFunc := missingInputTestRecord(&inp) + defer feeFunc.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Notifier: emptySpendNotifier(t, 1), + Mempool: mempool, + }) + + result := publisher.handleMissingInputs(record) + + require.Equal(t, TxUnknownSpend, result.Event) + require.ErrorIs(t, result.Err, ErrUnknownSpent) + require.Contains(t, result.SpentInputs, op) + require.Equal(t, spendingTx.TxHash(), result.SpentInputs[op].TxHash()) +} + +// TestFindMissingInputsWalletLookupError verifies that a wallet fallback error +// is surfaced so the caller can retry rather than classifying an input. +func TestFindMissingInputsWalletLookupError(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + op := inp.OutPoint() + + wallet := &MockWallet{} + wallet.On("FetchTx", op.Hash).Return(nil, errDummy).Once() + defer wallet.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Wallet: wallet, + IsInputUnspent: func(input.Input) (bool, error) { + return false, nil + }, + }) + + missing, err := publisher.findMissingInputs([]input.Input{&inp}) + + require.Nil(t, missing) + require.True(t, errors.Is(err, errDummy)) +} + +// TestHandleInitialTxErrorMissingInput verifies the initial error path routes +// ErrInputMissing through the missing-input classifier. +func TestHandleInitialTxErrorMissingInput(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + record, feeFunc := missingInputTestRecord(&inp) + defer feeFunc.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Notifier: emptySpendNotifier(t, 1), + IsInputUnspent: func(input.Input) (bool, error) { + return true, nil + }, + }) + resultChan := make(chan *BumpResult, 1) + publisher.subscriberChans.Store(record.requestID, resultChan) + + publisher.handleInitialTxError(record, ErrInputMissing) + + result := <-resultChan + require.Equal(t, TxFailed, result.Event) + require.ErrorIs(t, result.Err, ErrInputMissing) +} + +// TestHandleReplacementTxErrorMissingInput verifies the replacement path also +// routes ErrInputMissing through the missing-input classifier. +func TestHandleReplacementTxErrorMissingInput(t *testing.T) { + t.Parallel() + + inp := createTestInput(10_000, input.WitnessKeyHash) + record, feeFunc := missingInputTestRecord(&inp) + defer feeFunc.AssertExpectations(t) + + publisher := NewTxPublisher(TxPublisherConfig{ + Notifier: emptySpendNotifier(t, 1), + IsInputUnspent: func(input.Input) (bool, error) { + return true, nil + }, + }) + + resultOpt := publisher.handleReplacementTxError( + record, wire.NewMsgTx(2), ErrInputMissing, + ) + result, err := resultOpt.UnwrapOrErr(errors.New("missing bump result")) + + require.NoError(t, err) + require.Equal(t, TxFailed, result.Event) + require.ErrorIs(t, result.Err, ErrInputMissing) +}