diff --git a/lnwallet/interface.go b/lnwallet/interface.go index 9af7278cc..8553b7650 100644 --- a/lnwallet/interface.go +++ b/lnwallet/interface.go @@ -600,7 +600,7 @@ type MessageSigner interface { type AddrWithKey struct { lnwire.DeliveryAddress - InternalKey fn.Option[btcec.PublicKey] + InternalKey fn.Option[keychain.KeyDescriptor] // TODO(roasbeef): consolidate w/ instance in chan closer } diff --git a/server.go b/server.go index 21d39582a..d16bc1d24 100644 --- a/server.go +++ b/server.go @@ -4820,11 +4820,7 @@ func newSweepPkScriptGen( return fn.Ok(lnwallet.AddrWithKey{ DeliveryAddress: addr, - InternalKey: fn.MapOption( - func(desc keychain.KeyDescriptor) btcec.PublicKey { - return *desc.PubKey - }, - )(internalKeyDesc), + InternalKey: internalKeyDesc, }) } } diff --git a/sweep/fee_bumper.go b/sweep/fee_bumper.go index b4d429894..25cc49d42 100644 --- a/sweep/fee_bumper.go +++ b/sweep/fee_bumper.go @@ -111,7 +111,7 @@ type BumpRequest struct { DeadlineHeight int32 // DeliveryAddress is the script to send the change output to. - DeliveryAddress []byte + DeliveryAddress lnwallet.AddrWithKey // MaxFeeRate is the maximum fee rate that can be used for fee bumping. MaxFeeRate chainfee.SatPerKWeight @@ -119,6 +119,10 @@ type BumpRequest struct { // StartingFeeRate is an optional parameter that can be used to specify // the initial fee rate to use for the fee function. StartingFeeRate fn.Option[chainfee.SatPerKWeight] + + // ExtraTxOut tracks if this bump request has an optional set of extra + // outputs to add to the transaction. + ExtraTxOut fn.Option[SweepOutput] } // MaxFeeRateAllowed returns the maximum fee rate allowed for the given @@ -128,7 +132,9 @@ type BumpRequest struct { func (r *BumpRequest) MaxFeeRateAllowed() (chainfee.SatPerKWeight, error) { // Get the size of the sweep tx, which will be used to calculate the // budget fee rate. - size, err := calcSweepTxWeight(r.Inputs, r.DeliveryAddress) + size, err := calcSweepTxWeight( + r.Inputs, r.DeliveryAddress.DeliveryAddress, + ) if err != nil { return 0, err } @@ -249,6 +255,10 @@ type TxPublisherConfig struct { // Notifier is used to monitor the confirmation status of the tx. Notifier chainntnfs.ChainNotifier + + // AuxSweeper is an optional interface that can be used to shape the + // way the final sweep transaction is generated. + AuxSweeper fn.Option[AuxSweeper] } // TxPublisher is an implementation of the Bumper interface. It utilizes the @@ -401,16 +411,18 @@ func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest, for { // Create a new tx with the given fee rate and check its // mempool acceptance. - tx, fee, err := t.createAndCheckTx(req, f) + sweepCtx, err := t.createAndCheckTx(req, f) switch { case err == nil: // The tx is valid, return the request ID. - requestID := t.storeRecord(tx, req, f, fee) + requestID := t.storeRecord( + sweepCtx.tx, req, f, sweepCtx.fee, + ) log.Infof("Created tx %v for %v inputs: feerate=%v, "+ - "fee=%v, inputs=%v", tx.TxHash(), - len(req.Inputs), f.FeeRate(), fee, + "fee=%v, inputs=%v", sweepCtx.tx.TxHash(), + len(req.Inputs), f.FeeRate(), sweepCtx.fee, inputTypeSummary(req.Inputs)) return requestID, nil @@ -421,8 +433,8 @@ func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest, // We should at least start with a feerate above the // mempool min feerate, so if we get this error, it // means something is wrong earlier in the pipeline. - log.Errorf("Current fee=%v, feerate=%v, %v", fee, - f.FeeRate(), err) + log.Errorf("Current fee=%v, feerate=%v, %v", + sweepCtx.fee, f.FeeRate(), err) fallthrough @@ -434,8 +446,8 @@ func (t *TxPublisher) createRBFCompliantTx(req *BumpRequest, // increased or maxed out. for !increased { log.Debugf("Increasing fee for next round, "+ - "current fee=%v, feerate=%v", fee, - f.FeeRate()) + "current fee=%v, feerate=%v", + sweepCtx.fee, f.FeeRate()) // If the fee function tells us that we have // used up the budget, we will return an error @@ -484,30 +496,34 @@ func (t *TxPublisher) storeRecord(tx *wire.MsgTx, req *BumpRequest, // script, and the fee rate. In addition, it validates the tx's mempool // acceptance before returning a tx that can be published directly, along with // its fee. -func (t *TxPublisher) createAndCheckTx(req *BumpRequest, f FeeFunction) ( - *wire.MsgTx, btcutil.Amount, error) { +func (t *TxPublisher) createAndCheckTx(req *BumpRequest, + f FeeFunction) (*sweepTxCtx, error) { // Create the sweep tx with max fee rate of 0 as the fee function // guarantees the fee rate used here won't exceed the max fee rate. - tx, fee, err := t.createSweepTx( + sweepCtx, err := t.createSweepTx( req.Inputs, req.DeliveryAddress, f.FeeRate(), ) if err != nil { - return nil, fee, fmt.Errorf("create sweep tx: %w", err) + return sweepCtx, fmt.Errorf("create sweep tx: %w", err) } // Sanity check the budget still covers the fee. - if fee > req.Budget { - return nil, fee, fmt.Errorf("%w: budget=%v, fee=%v", - ErrNotEnoughBudget, req.Budget, fee) + if sweepCtx.fee > req.Budget { + return sweepCtx, fmt.Errorf("%w: budget=%v, fee=%v", + ErrNotEnoughBudget, req.Budget, sweepCtx.fee) } + // If we had an extra txOut, then we'll update the result to include + // it. + req.ExtraTxOut = sweepCtx.extraTxOut + // Validate the tx's mempool acceptance. - err = t.cfg.Wallet.CheckMempoolAcceptance(tx) + err = t.cfg.Wallet.CheckMempoolAcceptance(sweepCtx.tx) // Exit early if the tx is valid. if err == nil { - return tx, fee, nil + return sweepCtx, nil } // Print an error log if the chain backend doesn't support the mempool @@ -515,18 +531,18 @@ func (t *TxPublisher) createAndCheckTx(req *BumpRequest, f FeeFunction) ( if errors.Is(err, rpcclient.ErrBackendVersion) { log.Errorf("TestMempoolAccept not supported by backend, " + "consider upgrading it to a newer version") - return tx, fee, nil + return sweepCtx, nil } // We are running on a backend that doesn't implement the RPC // testmempoolaccept, eg, neutrino, so we'll skip the check. if errors.Is(err, chain.ErrUnimplemented) { log.Debug("Skipped testmempoolaccept due to not implemented") - return tx, fee, nil + return sweepCtx, nil } - return nil, fee, fmt.Errorf("tx=%v failed mempool check: %w", - tx.TxHash(), err) + return sweepCtx, fmt.Errorf("tx=%v failed mempool check: %w", + sweepCtx.tx.TxHash(), err) } // broadcast takes a monitored tx and publishes it to the network. Prior to the @@ -547,6 +563,17 @@ func (t *TxPublisher) broadcast(requestID uint64) (*BumpResult, error) { log.Debugf("Publishing sweep tx %v, num_inputs=%v, height=%v", txid, len(tx.TxIn), t.currentHeight.Load()) + // Before we go to broadcast, we'll notify the aux sweeper, if it's + // present of this new broadcast attempt. + err := fn.MapOptionZ(t.cfg.AuxSweeper, func(aux AuxSweeper) error { + return aux.NotifyBroadcast( + record.req, tx, record.fee, + ) + }) + if err != nil { + return nil, fmt.Errorf("unable to notify aux sweeper: %w", err) + } + // Set the event, and change it to TxFailed if the wallet fails to // publish it. event := TxPublished @@ -554,7 +581,7 @@ func (t *TxPublisher) broadcast(requestID uint64) (*BumpResult, error) { // Publish the sweeping tx with customized label. If the publish fails, // this error will be saved in the `BumpResult` and it will be removed // from being monitored. - err := t.cfg.Wallet.PublishTransaction( + err = t.cfg.Wallet.PublishTransaction( tx, labels.MakeLabel(labels.LabelTypeSweepTransaction, nil), ) if err != nil { @@ -922,7 +949,7 @@ func (t *TxPublisher) createAndPublishTx(requestID uint64, // NOTE: The fee function is expected to have increased its returned // fee rate after calling the SkipFeeBump method. So we can use it // directly here. - tx, fee, err := t.createAndCheckTx(r.req, r.feeFunction) + sweepCtx, err := t.createAndCheckTx(r.req, r.feeFunction) // If the error is fee related, we will return no error and let the fee // bumper retry it at next block. @@ -969,17 +996,17 @@ func (t *TxPublisher) createAndPublishTx(requestID uint64, // The tx has been created without any errors, we now register a new // record by overwriting the same requestID. t.records.Store(requestID, &monitorRecord{ - tx: tx, + tx: sweepCtx.tx, req: r.req, feeFunction: r.feeFunction, - fee: fee, + fee: sweepCtx.fee, }) // Attempt to broadcast this new tx. result, err := t.broadcast(requestID) if err != nil { log.Infof("Failed to broadcast replacement tx %v: %v", - tx.TxHash(), err) + sweepCtx.tx.TxHash(), err) return fn.None[BumpResult]() } @@ -1005,7 +1032,8 @@ func (t *TxPublisher) createAndPublishTx(requestID uint64, return fn.Some(*result) } - log.Infof("Replaced tx=%v with new tx=%v", oldTx.TxHash(), tx.TxHash()) + log.Infof("Replaced tx=%v with new tx=%v", oldTx.TxHash(), + sweepCtx.tx.TxHash()) // Otherwise, it's a successful RBF, set the event and return. result.Event = TxReplaced @@ -1118,17 +1146,28 @@ func calcCurrentConfTarget(currentHeight, deadline int32) uint32 { return confTarget } +// sweepTxCtx houses a sweep transaction with additional context. +type sweepTxCtx struct { + tx *wire.MsgTx + + fee btcutil.Amount + + extraTxOut fn.Option[SweepOutput] +} + // createSweepTx creates a sweeping tx based on the given inputs, change // address and fee rate. -func (t *TxPublisher) createSweepTx(inputs []input.Input, changePkScript []byte, - feeRate chainfee.SatPerKWeight) (*wire.MsgTx, btcutil.Amount, error) { +func (t *TxPublisher) createSweepTx(inputs []input.Input, + changePkScript lnwallet.AddrWithKey, + feeRate chainfee.SatPerKWeight) (*sweepTxCtx, error) { // Validate and calculate the fee and change amount. txFee, changeAmtOpt, locktimeOpt, err := prepareSweepTx( - inputs, changePkScript, feeRate, t.currentHeight.Load(), + inputs, changePkScript.DeliveryAddress, feeRate, + t.currentHeight.Load(), ) if err != nil { - return nil, 0, err + return nil, err } var ( @@ -1174,7 +1213,7 @@ func (t *TxPublisher) createSweepTx(inputs []input.Input, changePkScript []byte, // If there's a change amount, add it to the transaction. changeAmtOpt.WhenSome(func(changeAmt btcutil.Amount) { sweepTx.AddTxOut(&wire.TxOut{ - PkScript: changePkScript, + PkScript: changePkScript.DeliveryAddress, Value: int64(changeAmt), }) }) @@ -1185,7 +1224,7 @@ func (t *TxPublisher) createSweepTx(inputs []input.Input, changePkScript []byte, prevInputFetcher, err := input.MultiPrevOutFetcher(inputs) if err != nil { - return nil, 0, fmt.Errorf("error creating prev input fetcher "+ + return nil, fmt.Errorf("error creating prev input fetcher "+ "for hash cache: %v", err) } hashCache := txscript.NewTxSigHashes(sweepTx, prevInputFetcher) @@ -1213,14 +1252,17 @@ func (t *TxPublisher) createSweepTx(inputs []input.Input, changePkScript []byte, for idx, inp := range idxs { if err := addInputScript(idx, inp); err != nil { - return nil, 0, err + return nil, err } } log.Debugf("Created sweep tx %v for inputs:\n%v", sweepTx.TxHash(), inputTypeSummary(inputs)) - return sweepTx, txFee, nil + return &sweepTxCtx{ + tx: sweepTx, + fee: txFee, + }, nil } // prepareSweepTx returns the tx fee, an optional change amount and an optional @@ -1305,14 +1347,16 @@ func prepareSweepTx(inputs []input.Input, changePkScript []byte, // The value remaining after the required output and fees is the // change output. changeAmt := totalInput - requiredOutput - txFee - changeAmtOpt := fn.Some(changeAmt) // We'll calculate the dust limit for the given changePkScript since it // is variable. changeFloor := lnwallet.DustLimitForSize(len(changePkScript)) + changeAmtOpt := fn.Some(changeAmt) + // If the change amount is dust, we'll move it into the fees. - if changeAmt < changeFloor { + switch { + case changeAmt < changeFloor: log.Infof("Change amt %v below dustlimit %v, not adding "+ "change output", changeAmt, changeFloor) diff --git a/sweep/fee_bumper_test.go b/sweep/fee_bumper_test.go index 63a828654..a7b278702 100644 --- a/sweep/fee_bumper_test.go +++ b/sweep/fee_bumper_test.go @@ -21,12 +21,14 @@ import ( var ( // Create a taproot change script. - changePkScript = []byte{ - 0x51, 0x20, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + changePkScript = lnwallet.AddrWithKey{ + DeliveryAddress: []byte{ + 0x51, 0x20, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, } testInputCount atomic.Uint64 @@ -117,7 +119,9 @@ func TestCalcSweepTxWeight(t *testing.T) { require.Zero(t, weight) // Use a correct change script to test the success case. - weight, err = calcSweepTxWeight([]input.Input{&inp}, changePkScript) + weight, err = calcSweepTxWeight( + []input.Input{&inp}, changePkScript.DeliveryAddress, + ) require.NoError(t, err) // BaseTxSize 8 bytes @@ -137,7 +141,9 @@ func TestBumpRequestMaxFeeRateAllowed(t *testing.T) { inp := createTestInput(100, input.WitnessKeyHash) // The weight is 487. - weight, err := calcSweepTxWeight([]input.Input{&inp}, changePkScript) + weight, err := calcSweepTxWeight( + []input.Input{&inp}, changePkScript.DeliveryAddress, + ) require.NoError(t, err) // Define a test budget and calculates its fee rate. @@ -154,7 +160,9 @@ func TestBumpRequestMaxFeeRateAllowed(t *testing.T) { // Use a wrong change script to test the error case. name: "error calc weight", req: &BumpRequest{ - DeliveryAddress: []byte{1}, + DeliveryAddress: lnwallet.AddrWithKey{ + DeliveryAddress: []byte{1}, + }, }, expectedMaxFeeRate: 0, expectedErr: true, @@ -451,7 +459,7 @@ func TestCreateAndCheckTx(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Call the method under test. - _, _, err := tp.createAndCheckTx(tc.req, m.feeFunc) + _, err := tp.createAndCheckTx(tc.req, m.feeFunc) // Check the result is as expected. require.ErrorIs(t, err, tc.expectedErr) diff --git a/sweep/interface.go b/sweep/interface.go index 4b02f143c..41120613b 100644 --- a/sweep/interface.go +++ b/sweep/interface.go @@ -1,8 +1,12 @@ package sweep import ( + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/fn" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -57,3 +61,31 @@ type Wallet interface { // service. BackEnd() string } + +// SweepOutput is an output used to sweep funds from a channel output. +type SweepOutput struct { //nolint:revive + wire.TxOut + + // IsExtra indicates whether this output is an extra output that was + // added by a party other than the sweeper. + IsExtra bool + + // InternalKey is the taproot internal key of the extra output. This is + // None, if this isn't a taproot output. + InternalKey fn.Option[keychain.KeyDescriptor] +} + +// AuxSweeper is used to enable a 3rd party to further shape the sweeping +// transaction by adding a set of extra outputs to the sweeping transaction. +type AuxSweeper interface { + // DeriveSweepAddr takes a set of inputs, and the change address we'd + // use to sweep them, and maybe results an extra sweep output that we + // should add to the sweeping transaction. + DeriveSweepAddr(inputs []input.Input, + change lnwallet.AddrWithKey) fn.Result[SweepOutput] + + // NotifyBroadcast is used to notify external callers of the broadcast + // of a sweep transaction, generated by the passed BumpRequest. + NotifyBroadcast(req *BumpRequest, tx *wire.MsgTx, + totalFees btcutil.Amount) error +} diff --git a/sweep/sweeper.go b/sweep/sweeper.go index 9b9b6b0d9..b0c2cd1f8 100644 --- a/sweep/sweeper.go +++ b/sweep/sweeper.go @@ -817,7 +817,7 @@ func (s *UtxoSweeper) sweep(set InputSet) error { Inputs: set.Inputs(), Budget: set.Budget(), DeadlineHeight: set.DeadlineHeight(), - DeliveryAddress: sweepAddr.DeliveryAddress, + DeliveryAddress: sweepAddr, MaxFeeRate: s.cfg.MaxFeeRate.FeePerKWeight(), StartingFeeRate: set.StartingFeeRate(), // TODO(yy): pass the strategy here. diff --git a/sweep/tx_input_set_test.go b/sweep/tx_input_set_test.go index b6a87b378..ea3bda6b0 100644 --- a/sweep/tx_input_set_test.go +++ b/sweep/tx_input_set_test.go @@ -11,6 +11,7 @@ import ( "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -125,6 +126,7 @@ func TestNeedWalletInput(t *testing.T) { // Create a mock input that doesn't have required outputs. mockInput := &input.MockInput{} mockInput.On("RequiredTxOut").Return(nil) + mockInput.On("ResolutionBlob").Return(fn.None[tlv.Blob]()) defer mockInput.AssertExpectations(t) // Create a mock input that has required outputs.