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.
This commit is contained in:
Boris Nagaev 2024-10-17 21:41:34 -03:00
parent 697e8d6255
commit 2f22f96e73
No known key found for this signature in database
4 changed files with 535 additions and 6 deletions

View file

@ -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,

View file

@ -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

198
loopout_feerate.go Normal file
View file

@ -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
}

278
loopout_feerate_test.go Normal file
View file

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