From 88e75dba17fa24b4c5a55f848901ae965a12a9e6 Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:14:56 -0700 Subject: [PATCH 1/5] sweep: track sweep transaction on pending inputs Store the most recent sweep transaction on each SweeperInput when it is published or replaced via RBF. This allows callers of PendingInputs to access the current in-flight sweep transaction for each input. Update PendingInputResponse to expose the sweep tx externally. --- sweep/sweeper.go | 22 +++++++++++++++++++--- sweep/sweeper_test.go | 4 ++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/sweep/sweeper.go b/sweep/sweeper.go index e2163f6373c..664dbb5e450 100644 --- a/sweep/sweeper.go +++ b/sweep/sweeper.go @@ -204,6 +204,11 @@ type SweeperInput struct { // different from the DeadlineHeight in its params as it's an actual // value than an option. DeadlineHeight int32 + + // sweepTx is the most recent sweep transaction that spends this + // input. This is set when the sweep transaction is published or + // replaced via RBF. + sweepTx *wire.MsgTx } // String returns a human readable interpretation of the pending input. @@ -309,6 +314,11 @@ type PendingInputResponse struct { // MaturityHeight is the block height that this input's locktime will // be expired at. For inputs with no locktime this value is zero. MaturityHeight uint32 + + // SweepTx is the most recent sweep transaction that spends this + // input. This may be nil if the input has not yet been included in a + // published sweep transaction. + SweepTx *wire.MsgTx } // updateReq is an internal message we'll use to represent an external caller's @@ -920,7 +930,8 @@ func (s *UtxoSweeper) markInputsPendingPublish(set InputSet) { // markInputsPublished updates the sweeping tx in db and marks the list of // inputs as published. -func (s *UtxoSweeper) markInputsPublished(tr *TxRecord, set InputSet) error { +func (s *UtxoSweeper) markInputsPublished(tr *TxRecord, tx *wire.MsgTx, + set InputSet) error { // Mark this tx in db once successfully published. // // NOTE: this will behave as an overwrite, which is fine as the record @@ -959,6 +970,10 @@ func (s *UtxoSweeper) markInputsPublished(tr *TxRecord, set InputSet) error { // Update the input's latest fee rate. pi.lastFeeRate = chainfee.SatPerKWeight(tr.FeeRate) + + // Store the sweep transaction on the input so it can be + // returned via PendingSweeps. + pi.sweepTx = tx } return nil @@ -1098,6 +1113,7 @@ func (s *UtxoSweeper) handlePendingSweepsReq( Params: inp.params, DeadlineHeight: uint32(inp.DeadlineHeight), MaturityHeight: maturityHeight, + SweepTx: inp.sweepTx, } } @@ -1766,7 +1782,7 @@ func (s *UtxoSweeper) handleBumpEventTxReplaced(resp *bumpResp) error { } // Mark the inputs as published using the replacing tx. - return s.markInputsPublished(tr, resp.set) + return s.markInputsPublished(tr, newTx, resp.set) } // handleBumpEventTxPublished handles the case where the sweeping tx has been @@ -1782,7 +1798,7 @@ func (s *UtxoSweeper) handleBumpEventTxPublished(resp *bumpResp) error { // Inputs have been successfully published so we update their // states. - err := s.markInputsPublished(tr, resp.set) + err := s.markInputsPublished(tr, tx, resp.set) if err != nil { return err } diff --git a/sweep/sweeper_test.go b/sweep/sweeper_test.go index d97fd992504..5033584e518 100644 --- a/sweep/sweeper_test.go +++ b/sweep/sweeper_test.go @@ -150,7 +150,7 @@ func TestMarkInputsPublished(t *testing.T) { // First, check that when an error is returned from db, it's properly // returned here. mockStore.On("StoreTx", dummyTR).Return(dummyErr).Once() - err := s.markInputsPublished(dummyTR, nil) + err := s.markInputsPublished(dummyTR, nil, nil) require.ErrorIs(err, dummyErr) // We also expect the record has been marked as published. @@ -167,7 +167,7 @@ func TestMarkInputsPublished(t *testing.T) { // published. set.On("Inputs").Return([]input.Input{inputInit, inputPendingPublish}) - err = s.markInputsPublished(dummyTR, set) + err = s.markInputsPublished(dummyTR, nil, set) require.NoError(err) // We expect unchanged number of pending inputs. From 72b058a353e6c3beefa46f66c2cb9763ff30a664 Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:15:19 -0700 Subject: [PATCH 2/5] lnrpc+walletrpc: add raw_tx_hex to PendingSweep response Add a raw_tx_hex field to the PendingSweep proto message so users can obtain the hex-encoded sweep transaction for manual rebroadcasting. The field is populated by serializing the sweep tx in the PendingSweeps RPC handler when available. Closes #8470 --- lnrpc/walletrpc/walletkit.pb.go | 21 +++++++++++++++++---- lnrpc/walletrpc/walletkit.proto | 7 +++++++ lnrpc/walletrpc/walletkit.swagger.json | 4 ++++ lnrpc/walletrpc/walletkit_server.go | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index ef8e84ee182..fe6cb963bb8 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -2800,8 +2800,12 @@ type PendingSweep struct { // The block height which the input's locktime will expire at. Zero if the // input has no locktime. MaturityHeight uint32 `protobuf:"varint,15,opt,name=maturity_height,json=maturityHeight,proto3" json:"maturity_height,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The raw hex-encoded sweep transaction that currently spends this input. + // This may be empty if the input has not yet been included in a published + // sweep transaction. + RawTxHex string `protobuf:"bytes,16,opt,name=raw_tx_hex,json=rawTxHex,proto3" json:"raw_tx_hex,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PendingSweep) Reset() { @@ -2944,6 +2948,13 @@ func (x *PendingSweep) GetMaturityHeight() uint32 { return 0 } +func (x *PendingSweep) GetRawTxHex() string { + if x != nil { + return x.RawTxHex + } + return "" +} + type PendingSweepsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4664,7 +4675,7 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x13EstimateFeeResponse\x12\x1c\n" + "\n" + "sat_per_kw\x18\x01 \x01(\x03R\bsatPerKw\x125\n" + - "\x18min_relay_fee_sat_per_kw\x18\x02 \x01(\x03R\x13minRelayFeeSatPerKw\"\x90\x05\n" + + "\x18min_relay_fee_sat_per_kw\x18\x02 \x01(\x03R\x13minRelayFeeSatPerKw\"\xae\x05\n" + "\fPendingSweep\x12+\n" + "\boutpoint\x18\x01 \x01(\v2\x0f.lnrpc.OutPointR\boutpoint\x129\n" + "\fwitness_type\x18\x02 \x01(\x0e2\x16.walletrpc.WitnessTypeR\vwitnessType\x12\x1d\n" + @@ -4683,7 +4694,9 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\timmediate\x18\f \x01(\bR\timmediate\x12\x16\n" + "\x06budget\x18\r \x01(\x04R\x06budget\x12'\n" + "\x0fdeadline_height\x18\x0e \x01(\rR\x0edeadlineHeight\x12'\n" + - "\x0fmaturity_height\x18\x0f \x01(\rR\x0ematurityHeight\"\x16\n" + + "\x0fmaturity_height\x18\x0f \x01(\rR\x0ematurityHeight\x12\x1c\n" + + "\n" + + "raw_tx_hex\x18\x10 \x01(\tR\brawTxHex\"\x16\n" + "\x14PendingSweepsRequest\"W\n" + "\x15PendingSweepsResponse\x12>\n" + "\x0epending_sweeps\x18\x01 \x03(\v2\x17.walletrpc.PendingSweepR\rpendingSweeps\"\x9f\x02\n" + diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index 81176d90910..54a3eb15372 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -1228,6 +1228,13 @@ message PendingSweep { input has no locktime. */ uint32 maturity_height = 15; + + /* + The raw hex-encoded sweep transaction that currently spends this input. + This may be empty if the input has not yet been included in a published + sweep transaction. + */ + string raw_tx_hex = 16; } message PendingSweepsRequest { diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index e5a0946c103..7f461493ec2 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -1998,6 +1998,10 @@ "type": "integer", "format": "int64", "description": "The block height which the input's locktime will expire at. Zero if the\ninput has no locktime." + }, + "raw_tx_hex": { + "type": "string", + "description": "The raw hex-encoded sweep transaction that currently spends this input.\nThis may be empty if the input has not yet been included in a published\nsweep transaction." } } }, diff --git a/lnrpc/walletrpc/walletkit_server.go b/lnrpc/walletrpc/walletkit_server.go index bece6001d6a..774a5abd6fa 100644 --- a/lnrpc/walletrpc/walletkit_server.go +++ b/lnrpc/walletrpc/walletkit_server.go @@ -8,6 +8,7 @@ import ( "context" "encoding/base64" "encoding/binary" + "encoding/hex" "errors" "fmt" "maps" @@ -907,6 +908,19 @@ func (w *WalletKit) PendingSweeps(ctx context.Context, return uint64(feeRate.FeePerVByte()) }) + // Serialize the sweep transaction to hex if available. + var rawTxHex string + if inp.SweepTx != nil { + var txBuf bytes.Buffer + err := inp.SweepTx.Serialize(&txBuf) + if err != nil { + return nil, fmt.Errorf("failed to serialize "+ + "sweep transaction: %w", err) + } + + rawTxHex = hex.EncodeToString(txBuf.Bytes()) + } + ps := &PendingSweep{ Outpoint: op, WitnessType: witnessType, @@ -918,6 +932,7 @@ func (w *WalletKit) PendingSweeps(ctx context.Context, DeadlineHeight: inp.DeadlineHeight, RequestedSatPerVbyte: startingFeeRate, MaturityHeight: inp.MaturityHeight, + RawTxHex: rawTxHex, } rpcPendingSweeps = append(rpcPendingSweeps, ps) } From 98e148a4a05fb7061edb4106e007351feebf7916 Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:15:27 -0700 Subject: [PATCH 3/5] cli: inform user about pendingsweeps after bumpfee Print a message after a successful bumpfee call directing users to use pendingsweeps to monitor the sweep transaction and obtain the raw transaction hex if needed. --- cmd/commands/walletrpc_active.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/commands/walletrpc_active.go b/cmd/commands/walletrpc_active.go index dcb91a71ff5..023bcbe718f 100644 --- a/cmd/commands/walletrpc_active.go +++ b/cmd/commands/walletrpc_active.go @@ -368,6 +368,10 @@ func bumpFee(ctx *cli.Context) error { printRespJSON(resp) + fmt.Println("\nSweep parameters updated successfully. Use " + + "`lncli wallet pendingsweeps` to monitor the sweep " + + "transaction and obtain the raw transaction hex if needed.") + return nil } From c0a15d7c44110fb5a996a5cc0a3fa2132057341a Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:15:40 -0700 Subject: [PATCH 4/5] itest: assert raw_tx_hex in pending sweeps response Add assertions to the bump fee integration tests verifying that the raw_tx_hex field in the PendingSweeps response is populated and matches the actual sweep transaction in the mempool. --- itest/lnd_bump_fee.go | 91 +++++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/itest/lnd_bump_fee.go b/itest/lnd_bump_fee.go index 3b89e10886b..f21454fa787 100644 --- a/itest/lnd_bump_fee.go +++ b/itest/lnd_bump_fee.go @@ -1,6 +1,8 @@ package itest import ( + "bytes" + "encoding/hex" "fmt" "github.com/btcsuite/btcd/btcutil" @@ -62,31 +64,6 @@ func testBumpFeeLowBudget(ht *lntest.HarnessTest) { assertPendingSweepResp := func(budget uint64, deadline uint32) *wire.MsgTx { - // Alice should still have one pending sweep. - pendingSweep := ht.AssertNumPendingSweeps(alice, 1)[0] - - // Validate all fields returned from `PendingSweeps` are as - // expected. - require.Equal(ht, op.TxidBytes, pendingSweep.Outpoint.TxidBytes) - require.Equal(ht, op.OutputIndex, - pendingSweep.Outpoint.OutputIndex) - require.Equal(ht, walletrpc.WitnessType_TAPROOT_PUB_KEY_SPEND, - pendingSweep.WitnessType) - require.EqualValuesf(ht, value, pendingSweep.AmountSat, - "amount not matched: want=%d, got=%d", value, - pendingSweep.AmountSat) - require.True(ht, pendingSweep.Immediate) - - require.EqualValuesf(ht, budget, pendingSweep.Budget, - "budget not matched: want=%d, got=%d", budget, - pendingSweep.Budget) - - // Since the request doesn't specify a deadline, we expect the - // existing deadline to be used. - require.Equalf(ht, deadline, pendingSweep.DeadlineHeight, - "deadline height not matched: want=%d, got=%d", - deadline, pendingSweep.DeadlineHeight) - // We expect to see Alice's original tx and her CPFP tx in the // mempool. txns := ht.GetNumTxsFromMempool(2) @@ -98,6 +75,61 @@ func testBumpFeeLowBudget(ht *lntest.HarnessTest) { sweepTx = txns[1] } + // Serialize the mempool sweep tx so we can compare against the + // RawTxHex returned by the RPC. + var expectedBuf bytes.Buffer + require.NoError(ht, sweepTx.Serialize(&expectedBuf)) + expectedHex := hex.EncodeToString(expectedBuf.Bytes()) + + // Note that the sweeper updates the input's tracked sweep tx + // after broadcasting, so there is a brief window where the + // mempool has the new tx but the input still references the + // previous one. We retry the comparison here until both views + // converge. + err := wait.NoError(func() error { + // Alice should still have one pending sweep. + ps := ht.AssertNumPendingSweeps(alice, 1)[0] + + // Validate all fields returned from `PendingSweeps` + // are as expected. These fields don't change during + // the test so we assert them without retrying. + require.Equal(ht, op.TxidBytes, ps.Outpoint.TxidBytes) + require.Equal(ht, op.OutputIndex, + ps.Outpoint.OutputIndex) + require.Equal(ht, + walletrpc.WitnessType_TAPROOT_PUB_KEY_SPEND, + ps.WitnessType) + require.EqualValuesf(ht, value, ps.AmountSat, + "amount not matched: want=%d, got=%d", value, + ps.AmountSat) + require.True(ht, ps.Immediate) + require.EqualValuesf(ht, budget, ps.Budget, + "budget not matched: want=%d, got=%d", budget, + ps.Budget) + + // Since the request doesn't specify a deadline, we + // expect the existing deadline to be used. + require.Equalf(ht, deadline, ps.DeadlineHeight, + "deadline height not matched: want=%d, got=%d", + deadline, ps.DeadlineHeight) + + // Validate that the raw tx hex matches the sweep + // transaction observed in the mempool. This is the + // field that lags behind, so the comparison lives + // inside the retry loop. + if ps.RawTxHex == "" { + return fmt.Errorf("raw tx hex is empty") + } + if ps.RawTxHex != expectedHex { + return fmt.Errorf("raw tx hex mismatch: "+ + "want=%s, got=%s", expectedHex, + ps.RawTxHex) + } + + return nil + }, wait.DefaultTimeout) + require.NoError(ht, err, "raw tx hex did not converge") + return sweepTx } @@ -301,6 +333,15 @@ func runBumpFee(ht *lntest.HarnessTest, alice *node.HarnessNode) { sweepTx = txns[1] } + // Validate that the raw tx hex is populated and matches the + // sweep transaction. + ps := ht.AssertNumPendingSweeps(alice, 1)[0] + require.NotEmpty(ht, ps.RawTxHex) + var expectedBuf bytes.Buffer + require.NoError(ht, sweepTx.Serialize(&expectedBuf)) + expectedHex := hex.EncodeToString(expectedBuf.Bytes()) + require.Equal(ht, expectedHex, ps.RawTxHex) + return sweepTx } From 7ce753521ee01f74c5116f1a71085ba03db6368b Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Mon, 11 May 2026 11:34:00 -0700 Subject: [PATCH 5/5] docs: add release notes for raw_tx_hex on PendingSweep --- docs/release-notes/release-notes-0.22.0.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index c3367af9cc7..dd71137439a 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -27,6 +27,13 @@ ## RPC Additions +* [Added a `raw_tx_hex` field to the `PendingSweep` + response](https://github.com/lightningnetwork/lnd/pull/10670) returned by + `walletrpc.PendingSweeps`. The field contains the serialized hex of the most + recent sweep transaction spending the input, allowing callers (notably + consumers of `BumpFee`) to inspect or rebroadcast the in-flight sweep + without having to scrape the mempool. + ## lncli Additions # Improvements @@ -37,6 +44,11 @@ ## lncli Updates +* `lncli wallet bumpfee` now prints a hint pointing users at `lncli wallet + pendingsweeps` to inspect the in-flight sweep transaction, including its + raw hex + ([#10670](https://github.com/lightningnetwork/lnd/pull/10670)). + ## Breaking Changes ## Performance Improvements