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 } 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 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 } 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) } 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.