diff --git a/client.go b/client.go index 084cfe29..2254cf89 100644 --- a/client.go +++ b/client.go @@ -62,8 +62,14 @@ var ( // probeTimeout is the maximum time until a probe is allowed to take. probeTimeout = 3 * time.Minute + // repushDelay is the delay of (re)adding a sweep to sweepbatcher after + // a block is mined. repushDelay = 1 * time.Second + // additionalDelay is the delay added on top of repushDelay inside the + // sweepbatcher to publish a sweep transaction. + additionalDelay = 1 * time.Second + // MinerFeeEstimationFailed is a magic number that is returned in a // quote call as the miner fee if the fee estimation in lnd's wallet // failed because of insufficient funds. @@ -185,13 +191,52 @@ func NewClient(dbDir string, loopDB loopdb.SwapStore, "NewSweepFetcherFromSwapStore failed: %w", err) } + // There is circular dependency between executor and sweepbatcher, as + // executor stores sweepbatcher and sweepbatcher depends on + // executor.height() though loopOutSweepFeerateProvider. + var executor *executor + + // getHeight returns current height, according to executor. + getHeight := func() int32 { + if executor == nil { + // This must not happen, because executor is set in this + // function, before sweepbatcher.Run is called. + log.Errorf("getHeight called when executor is nil") + + return 0 + } + + return executor.height() + } + + loopOutSweepFeerateProvider := newLoopOutSweepFeerateProvider( + sweeper, loopDB, cfg.Lnd.ChainParams, getHeight, + ) + batcher := sweepbatcher.NewBatcher( cfg.Lnd.WalletKit, cfg.Lnd.ChainNotifier, cfg.Lnd.Signer, swapServerClient.MultiMuSig2SignSweep, verifySchnorrSig, cfg.Lnd.ChainParams, sweeperDb, sweepStore, + + // Disable 100 sats/kw fee bump every block and retarget feerate + // every block according to the current mempool condition. + sweepbatcher.WithCustomFeeRate( + loopOutSweepFeerateProvider.GetMinFeeRate, + ), + + // Upon new block arrival, republishing is triggered in both + // loopout.go code (waitForHtlcSpendConfirmedV2/ <-timerChan) + // and in sweepbatcher code (batch.Run/case <-timerChan). The + // former updates the fee rate which is used by the later by + // calling AddSweep. Make sure they are ordered, add additional + // delay time to sweepbatcher's handling. The delay used in + // loopout.go is repushDelay. + sweepbatcher.WithPublishDelay( + repushDelay+additionalDelay, + ), ) - executor := newExecutor(&executorConfig{ + executor = newExecutor(&executorConfig{ lnd: cfg.Lnd, store: loopDB, sweeper: sweeper, diff --git a/loopout.go b/loopout.go index 68559d4e..c866ef60 100644 --- a/loopout.go +++ b/loopout.go @@ -52,11 +52,19 @@ const ( DefaultHtlcConfTarget = 6 // DefaultSweepConfTargetDelta is the delta of blocks from a Loop Out - // swap's expiration height at which we begin to use the default sweep - // confirmation target. - // - // TODO(wilmer): tune? - DefaultSweepConfTargetDelta = DefaultSweepConfTarget * 2 + // swap's expiration height at which we begin to cap the sweep + // confirmation target with urgentSweepConfTarget and multiply feerate + // by factor urgentSweepConfTargetFactor. + DefaultSweepConfTargetDelta = 10 + + // urgentSweepConfTarget is the confirmation target we'll use when the + // loop-out swap is about to expire (<= DefaultSweepConfTargetDelta + // blocks to expire). + urgentSweepConfTarget = 3 + + // urgentSweepConfTargetFactor is the factor we apply to feerate of + // loop-out sweep if it is about to expire. + urgentSweepConfTargetFactor = 1.1 ) // loopOutSwap contains all the in-memory state related to a pending loop out diff --git a/loopout_feerate.go b/loopout_feerate.go new file mode 100644 index 00000000..91f62fcf --- /dev/null +++ b/loopout_feerate.go @@ -0,0 +1,198 @@ +package loop + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/utils" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// sweeper provides fee, fee rate and weight by confTarget. +type sweeper interface { + // GetSweepFeeDetails calculates the required tx fee to spend to + // destAddr. It takes a function that is expected to add the weight of + // the input to the weight estimator. It also takes a label used for + // logging. It returns also the fee rate and transaction weight. + GetSweepFeeDetails(ctx context.Context, + addInputEstimate func(*input.TxWeightEstimator) error, + destAddr btcutil.Address, sweepConfTarget int32, label string) ( + btcutil.Amount, chainfee.SatPerKWeight, lntypes.WeightUnit, + error) +} + +// loopOutFetcher provides the loop out swap with the given hash. +type loopOutFetcher interface { + // FetchLoopOutSwap returns the loop out swap with the given hash. + FetchLoopOutSwap(ctx context.Context, + hash lntypes.Hash) (*loopdb.LoopOut, error) +} + +// heightGetter returns current height known to the swap server. +type heightGetter func() int32 + +// loopOutSweepFeerateProvider provides sweepbatcher with the info about swap's +// current feerate for loop-out sweep. +type loopOutSweepFeerateProvider struct { + // sweeper provides fee, fee rate and weight by confTarget. + sweeper sweeper + + // loopOutFetcher loads LoopOut from DB by swap hash. + loopOutFetcher loopOutFetcher + + // chainParams are the chain parameters of the chain that is used by + // swaps. + chainParams *chaincfg.Params + + // getHeight returns current height known to the swap server. + getHeight heightGetter +} + +// newLoopOutSweepFeerateProvider builds and returns new instance of +// loopOutSweepFeerateProvider. +func newLoopOutSweepFeerateProvider(sweeper sweeper, + loopOutFetcher loopOutFetcher, chainParams *chaincfg.Params, + getHeight heightGetter) *loopOutSweepFeerateProvider { + + return &loopOutSweepFeerateProvider{ + sweeper: sweeper, + loopOutFetcher: loopOutFetcher, + chainParams: chainParams, + getHeight: getHeight, + } +} + +// GetMinFeeRate returns minimum required feerate for a sweep by swap hash. +func (p *loopOutSweepFeerateProvider) GetMinFeeRate(ctx context.Context, + swapHash lntypes.Hash) (chainfee.SatPerKWeight, error) { + + _, feeRate, err := p.GetConfTargetAndFeeRate(ctx, swapHash) + + return feeRate, err +} + +// GetConfTargetAndFeeRate returns conf target and minimum required feerate +// for a sweep by swap hash. +func (p *loopOutSweepFeerateProvider) GetConfTargetAndFeeRate( + ctx context.Context, swapHash lntypes.Hash) (int32, + chainfee.SatPerKWeight, error) { + + // Load the loop-out from DB. + loopOut, err := p.loopOutFetcher.FetchLoopOutSwap(ctx, swapHash) + if err != nil { + return 0, 0, fmt.Errorf("failed to load swap %x from DB: %w", + swapHash[:6], err) + } + + contract := loopOut.Contract + if contract == nil { + return 0, 0, fmt.Errorf("loop-out %x has nil Contract", + swapHash[:6]) + } + + // Determine if we can keyspend. + htlcVersion := utils.GetHtlcScriptVersion(contract.ProtocolVersion) + canKeyspend := htlcVersion >= swap.HtlcV3 + + // Find addInputToEstimator function. + var addInputToEstimator func(e *input.TxWeightEstimator) error + if canKeyspend { + // Assume the server is cooperative and we produce keyspend. + addInputToEstimator = func(e *input.TxWeightEstimator) error { + e.AddTaprootKeySpendInput(txscript.SigHashDefault) + + return nil + } + } else { + // Get the HTLC script for our swap. + htlc, err := utils.GetHtlc( + swapHash, &contract.SwapContract, p.chainParams, + ) + if err != nil { + return 0, 0, fmt.Errorf("failed to get HTLC: %w", err) + } + addInputToEstimator = htlc.AddSuccessToEstimator + } + + // Transaction weight might be important for feeRate, in case of high + // priority proportional fee, so we accurately assess the size of input. + // The size of output is almost the same for all types, so use P2TR. + var destAddr *btcutil.AddressTaproot + + // Get current height. + height := p.getHeight() + if height == 0 { + return 0, 0, fmt.Errorf("got zero best block height") + } + + // blocksUntilExpiry is the number of blocks until the htlc timeout path + // opens for the client to sweep. + blocksUntilExpiry := contract.CltvExpiry - height + + // Find confTarget. If the sweep has expired, use confTarget=1, because + // confTarget must be positive. + confTarget := blocksUntilExpiry + if confTarget <= 0 { + log.Infof("Swap %x has expired (blocksUntilExpiry=%d), using "+ + "confTarget=1 for it.", swapHash[:6], blocksUntilExpiry) + + confTarget = 1 + } + + feeFactor := float64(1.0) + + // If confTarget is less than or equal to DefaultSweepConfTargetDelta, + // cap it with urgentSweepConfTarget and apply fee factor. + if confTarget <= DefaultSweepConfTargetDelta { + // If confTarget is already <= urgentSweepConfTarget, don't + // increase it. + newConfTarget := int32(urgentSweepConfTarget) + if confTarget < newConfTarget { + newConfTarget = confTarget + } + + log.Infof("Swap %x is about to expire (blocksUntilExpiry=%d), "+ + "reducing its confTarget from %d to %d and multiplying"+ + " feerate by %v.", swapHash[:6], blocksUntilExpiry, + confTarget, newConfTarget, urgentSweepConfTargetFactor) + + confTarget = newConfTarget + feeFactor = urgentSweepConfTargetFactor + } + + // Construct the label. + label := fmt.Sprintf("loopout-sweep-%x", swapHash[:6]) + + // Estimate confTarget and feeRate. + _, feeRate, _, err := p.sweeper.GetSweepFeeDetails( + ctx, addInputToEstimator, destAddr, confTarget, label, + ) + if err != nil { + return 0, 0, fmt.Errorf("fee estimator failed, swapHash=%x, "+ + "confTarget=%d: %w", swapHash[:6], confTarget, err) + } + + // Multiply feerate by fee factor. + feeRate = chainfee.SatPerKWeight(float64(feeRate) * feeFactor) + + // Sanity check. Make sure fee rate is not too low. + const minFeeRate = chainfee.AbsoluteFeePerKwFloor + if feeRate < minFeeRate { + log.Infof("Got too low fee rate for swap %x: %v. Increasing "+ + "it to %v.", swapHash[:6], feeRate, minFeeRate) + + feeRate = minFeeRate + } + + log.Debugf("Estimated for swap %x: feeRate=%s, confTarget=%d.", + swapHash[:6], feeRate, confTarget) + + return confTarget, feeRate, nil +} diff --git a/loopout_feerate_test.go b/loopout_feerate_test.go new file mode 100644 index 00000000..0e969053 --- /dev/null +++ b/loopout_feerate_test.go @@ -0,0 +1,278 @@ +package loop + +import ( + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/sweep" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// testSweeper is implementation of sweeper.Sweeper for test. +type testSweeper struct { +} + +// GetSweepFeeDetails calculates the required tx fee to spend to destAddr. It +// takes a function that is expected to add the weight of the input to the +// weight estimator. It returns also the fee rate and transaction weight. +func (s testSweeper) GetSweepFeeDetails(ctx context.Context, + addInputEstimate func(*input.TxWeightEstimator) error, + destAddr btcutil.Address, sweepConfTarget int32, + label string) (btcutil.Amount, chainfee.SatPerKWeight, + lntypes.WeightUnit, error) { + + var feeRate chainfee.SatPerKWeight + switch { + case sweepConfTarget == 0: + return 0, 0, 0, fmt.Errorf("zero sweepConfTarget") + + case sweepConfTarget == 1: + feeRate = 30000 + + case sweepConfTarget == 2: + feeRate = 25000 + + case sweepConfTarget == 3: + feeRate = 20000 + + case sweepConfTarget < 10: + feeRate = 8000 + + case sweepConfTarget < 100: + feeRate = 5000 + + case sweepConfTarget < 1000: + feeRate = 2000 + + default: + feeRate = 250 + } + + // Calculate weight for this tx. + var weightEstimate input.TxWeightEstimator + + // Add output. + err := sweep.AddOutputEstimate(&weightEstimate, destAddr) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to add output weight "+ + "estimate: %w", err) + } + + // Add input. + err = addInputEstimate(&weightEstimate) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to add input weight "+ + "estimate: %w", err) + } + + // Find weight. + weight := weightEstimate.Weight() + + return feeRate.FeeForWeight(weight), feeRate, weight, nil +} + +// TestLoopOutSweepFeerateProvider tests that loopOutSweepFeerateProvider +// provides correct fee rate for loop-out swaps. +func TestLoopOutSweepFeerateProvider(t *testing.T) { + htlcKeys := func() loopdb.HtlcKeys { + var senderKey, receiverKey [33]byte + + // Generate keys. + _, senderPubKey := test.CreateKey(1) + copy(senderKey[:], senderPubKey.SerializeCompressed()) + _, receiverPubKey := test.CreateKey(2) + copy(receiverKey[:], receiverPubKey.SerializeCompressed()) + + return loopdb.HtlcKeys{ + SenderScriptKey: senderKey, + ReceiverScriptKey: receiverKey, + SenderInternalPubKey: senderKey, + ReceiverInternalPubKey: receiverKey, + } + }() + + var destAddr *btcutil.AddressTaproot + + swapInvoice := "lntb1230n1pjjszzgpp5j76f03wrkya4sm4gxv6az5nmz5aqsvmn4" + + "tpguu2sdvdyygedqjgqdq9xyerxcqzzsxqr23ssp5rwzmwtfjmsgranfk8sr" + + "4p4gcgmvyd42uug8pxteg2mkk23ndvkqs9qyyssq44ruk3ex59cmv4dm6k4v" + + "0kc6c0gcqjs0gkljfyd6c6uatqa2f67xlx3pcg5tnvcae5p3jju8ra77e87d" + + "vhhs0jrx53wnc0fq9rkrhmqqelyx7l" + + cases := []struct { + name string + cltvExpiry int32 + height int32 + amount btcutil.Amount + protocolVersion loopdb.ProtocolVersion + wantConfTarget int32 + wantFeeRate chainfee.SatPerKWeight + wantError string + }{ + { + name: "simple case", + cltvExpiry: 801_000, + height: 800_900, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 100, + wantFeeRate: 2000, + }, + { + name: "zero height", + cltvExpiry: 801_000, + height: 0, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantError: "got zero best block height", + }, + { + name: "huge amount, no proportional fee", + cltvExpiry: 801_000, + height: 800_900, + amount: 100_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 100, + wantFeeRate: 2000, + }, + { + name: "huge amount, no proportional fee, v2", + cltvExpiry: 801_000, + height: 800_900, + amount: 100_000_000, + protocolVersion: loopdb.ProtocolVersionLoopOutCancel, + wantConfTarget: 100, + wantFeeRate: 2000, + }, + { + name: "huge amount, no proportional fee, " + + "capped by urgent fee", + cltvExpiry: 801_000, + height: 800_900, + amount: 200_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 100, + wantFeeRate: 2000, + }, + { + name: "11 blocks until expiry", + cltvExpiry: 801_000, + height: 800_989, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 11, + wantFeeRate: 5000, + }, + { + name: "10 blocks until expiry", + cltvExpiry: 801_000, + height: 800_990, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 3, + wantFeeRate: 22000, + }, + { + name: "9 blocks until expiry", + cltvExpiry: 801_000, + height: 800_991, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 3, + wantFeeRate: 22000, + }, + { + name: "3 blocks until expiry", + cltvExpiry: 801_000, + height: 800_997, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 3, + wantFeeRate: 22000, + }, + { + name: "2 blocks until expiry", + cltvExpiry: 801_000, + height: 800_998, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 2, + wantFeeRate: 27500, + }, + { + name: "1 blocks until expiry", + cltvExpiry: 801_000, + height: 800_999, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 1, + wantFeeRate: 33000, + }, + { + name: "expired", + cltvExpiry: 801_000, + height: 801_000, + amount: 1_000_000, + protocolVersion: loopdb.ProtocolVersionMuSig2, + wantConfTarget: 1, + wantFeeRate: 33000, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := loopdb.NewStoreMock(t) + + ctx := context.Background() + + swapHash := lntypes.Hash{1, 1, 1} + swap := &loopdb.LoopOutContract{ + SwapContract: loopdb.SwapContract{ + CltvExpiry: tc.cltvExpiry, + AmountRequested: tc.amount, + ProtocolVersion: tc.protocolVersion, + HtlcKeys: htlcKeys, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 100, + } + + err := store.CreateLoopOut(ctx, swapHash, swap) + require.NoError(t, err) + store.AssertLoopOutStored() + + getHeight := func() int32 { + return tc.height + } + + p := newLoopOutSweepFeerateProvider( + testSweeper{}, store, + &chaincfg.RegressionNetParams, getHeight, + ) + + confTarget, feeRate, err := p.GetConfTargetAndFeeRate( + ctx, swapHash, + ) + if tc.wantError != "" { + require.ErrorContains(t, err, tc.wantError) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.wantConfTarget, confTarget) + require.Equal(t, tc.wantFeeRate, feeRate) + }) + } +}