From faa503742430aea340074cc6e3920dcf34780f6f Mon Sep 17 00:00:00 2001 From: Julio Cesar Date: Mon, 25 May 2026 23:14:09 +0200 Subject: [PATCH] Avoid fatal sweeper state on mempool check failure --- sweep/fee_bumper.go | 24 ++++++++++++++++++++++-- sweep/fee_bumper_test.go | 6 ++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index e0d5d751616..a5bdf7bef44 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -47,6 +47,11 @@ var ( // ErrInputMissing is returned when a given input no longer exists, // e.g., spending from an orphan tx. ErrInputMissing = errors.New("input no longer exists") + + // ErrMempoolAcceptance is returned when a transaction fails + // testmempoolaccept for a reason that doesn't have a more specific + // sweeper error type. + ErrMempoolAcceptance = errors.New("mempool acceptance check failed") ) var ( @@ -673,8 +678,8 @@ func (t *TxPublisher) createAndCheckTx(r *monitorRecord) (*sweepTxCtx, error) { return sweepCtx, ErrInputMissing } - return sweepCtx, fmt.Errorf("tx=%v failed mempool check: %w", - sweepCtx.tx.TxHash(), err) + return sweepCtx, fmt.Errorf("%w: tx=%v: %w", + ErrMempoolAcceptance, sweepCtx.tx.TxHash(), err) } // handleMissingInputs handles the case when the chain backend reports back a @@ -1142,6 +1147,21 @@ func (t *TxPublisher) handleInitialTxError(r *monitorRecord, err error) { case errors.Is(err, ErrInputMissing): result = t.handleMissingInputs(r) + // If testmempoolaccept rejects the initial sweep tx for a + // non-fee-related reason, don't terminally fail the entire input set. + // The failure may be caused by a single bad witness in a larger batch, + // so let the sweeper retry and re-cluster the inputs instead. + case errors.Is(err, ErrMempoolAcceptance): + result.Event = TxFailed + + feeRate, err := t.calculateRetryFeeRate(r) + if err != nil { + result.Event = TxFatal + result.Err = err + } + + result.FeeRate = feeRate + // Otherwise this is not a fee-related error and the tx cannot be // retried. In that case we will fail ALL the inputs in this tx, which // means they will be removed from the sweeper and never be tried diff --git a/sweep/fee_bumper_test.go b/sweep/fee_bumper_test.go index d697f906b2a..22bda41d05e 100644 --- a/sweep/fee_bumper_test.go +++ b/sweep/fee_bumper_test.go @@ -1944,8 +1944,10 @@ func TestHandleInitialBroadcastFail(t *testing.T) { t.Fatal("timeout waiting for subscriber to receive result") case result := <-resultChan: - // We expect the first result to be TxFatal. - require.Equal(t, TxFatal, result.Event) + // A non-fee-related testmempoolaccept failure should be + // retried by the sweeper instead of terminally failing all + // inputs in the initial batch. + require.Equal(t, TxFailed, result.Event) } // Validate the record was NOT stored.