From 1fddf94f8eb09e0b43b6fc5c5ebc6204f35d10e5 Mon Sep 17 00:00:00 2001 From: pinglanlu Date: Mon, 11 Nov 2024 18:44:01 +0800 Subject: [PATCH 1/5] chore: remove redundant word in release.sh Signed-off-by: pinglanlu --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.sh b/release.sh index 0c83b6d1b..82b55ffea 100755 --- a/release.sh +++ b/release.sh @@ -18,7 +18,7 @@ if [[ $1x = x ]]; then else TAG=$1 - # If a tag is specified, ensure that that tag is present and checked out. + # If a tag is specified, ensure that tag is present and checked out. if [[ $TAG != $(git describe) ]]; then echo "tag $TAG not checked out" exit 1 From 6c62ded35bc5080a5c6099a6d7afd5dfedc3e171 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 18 Oct 2024 13:43:00 -0300 Subject: [PATCH 2/5] loopout: remove dead code The first result of function sweepConfTarget was not used. The function was renamed to canSweep to better reflect what it does. --- loopout.go | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/loopout.go b/loopout.go index 6be118c15..4c45f989d 100644 --- a/loopout.go +++ b/loopout.go @@ -1169,12 +1169,12 @@ func (s *loopOutSwap) waitForHtlcSpendConfirmedV2(globalCtx context.Context, timerChan = s.timerFactory(repushDelay) case <-timerChan: - // sweepConfTarget will return false if the preimage is + // canSweep will return false if the preimage is // not revealed yet but the conf target is closer than // 20 blocks. In this case to be sure we won't attempt // to sweep at all and we won't reveal the preimage // either. - _, canSweep := s.sweepConfTarget() + canSweep := s.canSweep() if !canSweep { s.log.Infof("Aborting swap, timed " + "out on-chain") @@ -1375,9 +1375,9 @@ func validateLoopOutContract(lnd *lndclient.LndServices, request *OutRequest, return nil } -// sweepConfTarget returns the confirmation target for the htlc sweep or false -// if we're too late. -func (s *loopOutSwap) sweepConfTarget() (int32, bool) { +// canSweep will return false if the preimage is not revealed yet but the conf +// target is closer than 20 blocks (i.e. it is too late to reveal the preimage). +func (s *loopOutSwap) canSweep() bool { remainingBlocks := s.CltvExpiry - s.height blocksToLastReveal := remainingBlocks - MinLoopOutPreimageRevealDelta preimageRevealed := s.state == loopdb.StatePreimageRevealed @@ -1393,20 +1393,8 @@ func (s *loopOutSwap) sweepConfTarget() (int32, bool) { s.height) s.state = loopdb.StateFailTimeout - return 0, false + return false } - // Calculate the transaction fee based on the confirmation target - // required to sweep the HTLC before the timeout. We'll use the - // confirmation target provided by the client unless we've come too - // close to the expiration height, in which case we'll use the default - // if it is better than what the client provided. - confTarget := s.SweepConfTarget - if remainingBlocks <= DefaultSweepConfTargetDelta && - confTarget > DefaultSweepConfTarget { - - confTarget = DefaultSweepConfTarget - } - - return confTarget, true + return true } From f8c160715412e9c9160101af87547d23f1eb9b0f Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 18 Oct 2024 20:52:53 -0300 Subject: [PATCH 3/5] loopout, liquidity: turn vars to constants There are some numeric constants that used to be defined as vars complicating their usage. They were turned into constants: MinLoopOutPreimageRevealDelta, DefaultSweepConfTarget, DefaultHtlcConfTarget, DefaultSweepConfTargetDelta. Also make liquidity.defaultHtlcConfTarget a constant, not a var. --- liquidity/liquidity.go | 4 ++-- loopout.go | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 8412e0784..fbd842b1f 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -106,13 +106,13 @@ const ( // time we reach timeout. We set this to a high estimate so that we can // account for worst-case fees, (1250 * 4 / 1000) = 50 sat/byte. defaultLoopInSweepFee = chainfee.SatPerKWeight(1250) -) -var ( // defaultHtlcConfTarget is the default confirmation target we use for // loop in swap htlcs, set to the same default at the client. defaultHtlcConfTarget = loop.DefaultHtlcConfTarget +) +var ( // defaultBudget is the default autoloop budget we set. This budget will // only be used for automatically dispatched swaps if autoloop is // explicitly enabled, so we are happy to set a non-zero value here. The diff --git a/loopout.go b/loopout.go index 4c45f989d..68559d4ed 100644 --- a/loopout.go +++ b/loopout.go @@ -38,20 +38,18 @@ const ( // We'll try to sweep with MuSig2 at most 10 times. If that fails we'll // fail back to using standard scriptspend sweep. maxMusigSweepRetries = 10 -) -var ( // MinLoopOutPreimageRevealDelta configures the minimum number of // remaining blocks before htlc expiry required to reveal preimage. - MinLoopOutPreimageRevealDelta int32 = 20 + MinLoopOutPreimageRevealDelta = 20 // DefaultSweepConfTarget is the default confirmation target we'll use // when sweeping on-chain HTLCs. - DefaultSweepConfTarget int32 = 9 + DefaultSweepConfTarget = 9 // DefaultHtlcConfTarget is the default confirmation target we'll use // for on-chain htlcs published by the swap client for Loop In. - DefaultHtlcConfTarget int32 = 6 + 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 From 697e8d625539322358c7d03316e90aa97c37cc9d Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 21 Oct 2024 15:35:32 -0300 Subject: [PATCH 4/5] sweep: log tx label in GetSweepFee methods Added argument 'label' to GetSweepFee and GetSweepFeeDetails. It is logged together with expected weight, fee and feerate. --- client.go | 3 +++ loopd/log.go | 4 ++++ loopin.go | 4 +++- loopin_test.go | 4 +++- sweep/log.go | 26 ++++++++++++++++++++++++++ sweep/sweeper.go | 20 ++++++++++++++------ 6 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 sweep/log.go diff --git a/client.go b/client.go index f116f6b38..084cfe29c 100644 --- a/client.go +++ b/client.go @@ -570,8 +570,11 @@ func (s *Client) getLoopOutSweepFee(ctx context.Context, confTarget int32) ( htlc = swap.QuoteHtlcP2WSH } + label := "loopout-quote" + return s.sweeper.GetSweepFee( ctx, htlc.AddSuccessToEstimator, p2wshAddress, confTarget, + label, ) } diff --git a/loopd/log.go b/loopd/log.go index a4f433f01..6ef5a74be 100644 --- a/loopd/log.go +++ b/loopd/log.go @@ -11,6 +11,7 @@ import ( "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/notifications" + "github.com/lightninglabs/loop/sweep" "github.com/lightninglabs/loop/sweepbatcher" "github.com/lightningnetwork/lnd" "github.com/lightningnetwork/lnd/build" @@ -52,6 +53,9 @@ func SetupLoggers(root *build.RotatingLogWriter, intercept signal.Interceptor) { lnd.AddSubLogger( root, notifications.Subsystem, intercept, notifications.UseLogger, ) + lnd.AddSubLogger( + root, sweep.Subsystem, intercept, sweep.UseLogger, + ) } // genSubLogger creates a logger for a subsystem. We provide an instance of diff --git a/loopin.go b/loopin.go index b72d1795f..21e40dcba 100644 --- a/loopin.go +++ b/loopin.go @@ -1077,10 +1077,12 @@ func (s *loopInSwap) publishTimeoutTx(ctx context.Context, } } + label := fmt.Sprintf("loopin-timeout-%x", s.hash[:6]) + // Calculate sweep tx fee. fee, err := s.sweeper.GetSweepFee( ctx, s.htlc.AddTimeoutToEstimator, s.timeoutAddr, - TimeoutTxConfTarget, + TimeoutTxConfTarget, label, ) if err != nil { return 0, err diff --git a/loopin_test.go b/loopin_test.go index 32f052100..5f403ba81 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -312,11 +312,13 @@ func handleHtlcExpiry(t *testing.T, ctx *loopInTestContext, inSwap *loopInSwap, // Expect timeout tx to be published. timeoutTx := <-ctx.lnd.TxPublishChannel + label := fmt.Sprintf("loopin-timeout-%x", inSwap.hash[:6]) + // We can just get our sweep fee as we would in the swap code because // our estimate is static. fee, err := inSwap.sweeper.GetSweepFee( context.Background(), inSwap.htlc.AddTimeoutToEstimator, - inSwap.timeoutAddr, TimeoutTxConfTarget, + inSwap.timeoutAddr, TimeoutTxConfTarget, label, ) require.NoError(t, err) cost.Onchain += fee diff --git a/sweep/log.go b/sweep/log.go new file mode 100644 index 000000000..3d0931a4c --- /dev/null +++ b/sweep/log.go @@ -0,0 +1,26 @@ +package sweep + +import ( + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem defines the sub system name of this package. +const Subsystem = "SWP" + +// log is a logger that is initialized with no output filters. This means the +// package will not perform any logging by default until the caller requests +// it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. This +// should be used in preference to SetLogWriter if the caller is also using +// btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/sweep/sweeper.go b/sweep/sweeper.go index e6b3df462..edcce0256 100644 --- a/sweep/sweeper.go +++ b/sweep/sweeper.go @@ -177,15 +177,15 @@ func (s *Sweeper) CreateSweepTx( // GetSweepFee calculates the required tx fee to spend to P2WKH. It takes a // function that is expected to add the weight of the input to the weight -// estimator. +// estimator. It also takes a label used for logging. func (s *Sweeper) GetSweepFee(ctx context.Context, addInputEstimate func(*input.TxWeightEstimator) error, - destAddr btcutil.Address, sweepConfTarget int32) ( + destAddr btcutil.Address, sweepConfTarget int32, label string) ( btcutil.Amount, error) { // Use GetSweepFeeDetails to get the fee and other unused data. fee, _, _, err := s.GetSweepFeeDetails( - ctx, addInputEstimate, destAddr, sweepConfTarget, + ctx, addInputEstimate, destAddr, sweepConfTarget, label, ) return fee, err @@ -193,10 +193,11 @@ func (s *Sweeper) GetSweepFee(ctx context.Context, // GetSweepFeeDetails calculates the required tx fee to spend to P2WKH. 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. +// estimator. It also takes a label used for logging. It returns also the fee +// rate and transaction weight. func (s *Sweeper) GetSweepFeeDetails(ctx context.Context, addInputEstimate func(*input.TxWeightEstimator) error, - destAddr btcutil.Address, sweepConfTarget int32) ( + destAddr btcutil.Address, sweepConfTarget int32, label string) ( btcutil.Amount, chainfee.SatPerKWeight, lntypes.WeightUnit, error) { // Get fee estimate from lnd. @@ -224,7 +225,14 @@ func (s *Sweeper) GetSweepFeeDetails(ctx context.Context, // Find weight. weight := weightEstimate.Weight() - return feeRate.FeeForWeight(weight), feeRate, weight, nil + // Find fee. + fee := feeRate.FeeForWeight(weight) + + log.Debugf("Estimations for a tx (label=%s): weight=%v, fee=%v, "+ + "feerate=%v, sweepConfTarget=%d.", label, weight, fee, feeRate, + sweepConfTarget) + + return fee, feeRate, weight, nil } // AddOutputEstimate adds output to weight estimator. From 2f22f96e73814dc4a449f4f15ac6a5becacab9c1 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Thu, 17 Oct 2024 21:41:34 -0300 Subject: [PATCH 5/5] loopout: re-target sweep's feerate every block Add type loopOutSweepFeerateProvider which determines confTarget based on distance to swap expiration, then determines feerate and fee using. Fee rate is plugged into sweepbatcher using WithCustomFeeRate. Option WithPublishDelay is used to make sure fee-rate is updated by loopout.go before the value is used by sweepbatcher. When determining confTarget, there are few adjustments over raw distance to cltv_expiry: - make sure confTarget is positive (if the swap has expired, raw distance is negative) - If confTarget is less than or equal to DefaultSweepConfTargetDelta (10), cap it with urgentSweepConfTarget and apply fee factor (1.1x). Also, if feerate is less than floor (1 sat/vbyte), then the floor is used. DefaultSweepConfTargetDelta was decreased from 18 to 10. Every block 100 sats/kw fee bump is disabled. Sweepbatcher re-targets feerate every block according to current mempool conditions and the number of blocks until expiry. Added tests for loopOutSweepFeerateProvider simulating various conditions. --- client.go | 47 ++++++- loopout.go | 18 ++- loopout_feerate.go | 198 ++++++++++++++++++++++++++++ loopout_feerate_test.go | 278 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 535 insertions(+), 6 deletions(-) create mode 100644 loopout_feerate.go create mode 100644 loopout_feerate_test.go diff --git a/client.go b/client.go index 084cfe29c..2254cf895 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 68559d4ed..c866ef60d 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 000000000..91f62fcfb --- /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 000000000..0e9690539 --- /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) + }) + } +}