diff --git a/chainreg/chainregistry.go b/chainreg/chainregistry.go index 71e9c04a6c4..e30d656e008 100644 --- a/chainreg/chainregistry.go +++ b/chainreg/chainregistry.go @@ -235,7 +235,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) { MinHtlcIn: cfg.Bitcoin.MinHTLCIn, FeeEstimator: chainfee.NewStaticEstimator( DefaultBitcoinStaticFeePerKW, - DefaultBitcoinStaticMinRelayFeeRate, + cfg.Fee.FeeFloorKW(), ), } @@ -409,6 +409,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) { cc.FeeEstimator, err = chainfee.NewBitcoindEstimator( *rpcConfig, bitcoindMode.EstimateMode, fallBackFeeRate.FeePerKWeight(), + cfg.Fee.FeeFloorKW(), ) if err != nil { return nil, nil, err @@ -672,6 +673,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) { fallBackFeeRate := chainfee.SatPerKVByte(25 * 1000) cc.FeeEstimator, err = chainfee.NewBtcdEstimator( *rpcConfig, fallBackFeeRate.FeePerKWeight(), + cfg.Fee.FeeFloorKW(), ) if err != nil { return nil, nil, err @@ -729,6 +731,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) { !cacheFees, cfg.Fee.MinUpdateTimeout, cfg.Fee.MaxUpdateTimeout, + cfg.Fee.MinRelayFeeRate.FeePerKVByte(), ) if err != nil { return nil, nil, err diff --git a/config.go b/config.go index e3d8850e581..dd38a3256cb 100644 --- a/config.go +++ b/config.go @@ -676,6 +676,7 @@ func DefaultConfig() Config { Fee: &lncfg.Fee{ MinUpdateTimeout: lncfg.DefaultMinUpdateTimeout, MaxUpdateTimeout: lncfg.DefaultMaxUpdateTimeout, + MinRelayFeeRate: lncfg.DefaultMinRelayFeeRate, }, SubRPCServers: &subRPCServerConfigs{ diff --git a/lncfg/fee.go b/lncfg/fee.go index c96e58022e5..cb477b610fb 100644 --- a/lncfg/fee.go +++ b/lncfg/fee.go @@ -1,6 +1,11 @@ package lncfg -import "time" +import ( + "fmt" + "time" + + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) // DefaultMinUpdateTimeout represents the minimum interval in which a // WebAPIEstimator will request fresh fees from its API. @@ -10,11 +15,32 @@ const DefaultMinUpdateTimeout = 5 * time.Minute // WebAPIEstimator will request fresh fees from its API. const DefaultMaxUpdateTimeout = 20 * time.Minute +// DefaultMinRelayFeeRate is the default minimum relay fee rate floor in +// sat/vb. 1 sat/vb equals 250 sat/kw; the relay floor is set to 1 sat/vb +// so that the effective floor remains FeePerKwFloor (253 sat/kw) after +// the minFeeManager rounds up from the backend's reported value. +const DefaultMinRelayFeeRate chainfee.SatPerVByte = 1 + // Fee holds the configuration options for fee estimation. // //nolint:ll type Fee struct { - URL string `long:"url" description:"Optional URL for external fee estimation. If no URL is specified, the method for fee estimation will depend on the chosen backend and network. Must be set for neutrino on mainnet."` - MinUpdateTimeout time.Duration `long:"min-update-timeout" description:"The minimum interval in which fees will be updated from the specified fee URL."` - MaxUpdateTimeout time.Duration `long:"max-update-timeout" description:"The maximum interval in which fees will be updated from the specified fee URL."` + URL string `long:"url" description:"Optional URL for external fee estimation. If no URL is specified, the method for fee estimation will depend on the chosen backend and network. Must be set for neutrino on mainnet."` + MinUpdateTimeout time.Duration `long:"min-update-timeout" description:"The minimum interval in which fees will be updated from the specified fee URL."` + MaxUpdateTimeout time.Duration `long:"max-update-timeout" description:"The maximum interval in which fees will be updated from the specified fee URL."` + MinRelayFeeRate chainfee.SatPerVByte `long:"min-relay-feerate" description:"Minimum fee rate floor in sat/vb used when estimating and enforcing on-chain fees. Lowering this below 1 sat/vb is only safe when your Bitcoin backend is configured with a matching minrelaytxfee and you control the path to miners. Default: 1 sat/vb (250 sat/kw)."` +} + +// Validate checks the fee configuration for invalid values. +func (f *Fee) Validate() error { + if f.MinRelayFeeRate < 0 { + return fmt.Errorf("fee.min-relay-feerate must be >= 0") + } + + return nil +} + +// FeeFloorKW returns the configured minimum relay fee rate as sat/kw. +func (f *Fee) FeeFloorKW() chainfee.SatPerKWeight { + return f.MinRelayFeeRate.FeePerKWeight() } diff --git a/lnwallet/chainfee/estimator.go b/lnwallet/chainfee/estimator.go index f83cce28512..397f0dd1fbd 100644 --- a/lnwallet/chainfee/estimator.go +++ b/lnwallet/chainfee/estimator.go @@ -147,6 +147,10 @@ type BtcdEstimator struct { // produce fee estimates. fallbackFeePerKW SatPerKWeight + // feeFloor is the minimum fee rate the minFeeManager will enforce. + // When zero, FeePerKwFloor is used as the default. + feeFloor SatPerKWeight + // minFeeManager is used to query the current minimum fee, in sat/kw, // that we should enforce. This will be used to determine fee rate for // a transaction when the estimated fee rate is too low to allow the @@ -167,7 +171,8 @@ type BtcdEstimator struct { // the occasion that the estimator has insufficient data, or returns zero for a // fee estimate. func NewBtcdEstimator(rpcConfig rpcclient.ConnConfig, - fallBackFeeRate SatPerKWeight) (*BtcdEstimator, error) { + fallBackFeeRate SatPerKWeight, + feeFloor SatPerKWeight) (*BtcdEstimator, error) { rpcConfig.DisableConnectOnNew = true rpcConfig.DisableAutoReconnect = false @@ -182,6 +187,7 @@ func NewBtcdEstimator(rpcConfig rpcclient.ConnConfig, return &BtcdEstimator{ fallbackFeePerKW: fallBackFeeRate, + feeFloor: feeFloor, btcdConn: chainConn, filterManager: newFilterManager(fetchCb), }, nil @@ -200,7 +206,7 @@ func (b *BtcdEstimator) Start() error { // can initialise the minimum relay fee manager which queries the // chain backend for the minimum relay fee on construction. minRelayFeeManager, err := newMinFeeManager( - defaultUpdateInterval, b.fetchMinRelayFee, + defaultUpdateInterval, b.fetchMinRelayFee, b.feeFloor, ) if err != nil { return err @@ -341,6 +347,10 @@ type BitcoindEstimator struct { // produce fee estimates. fallbackFeePerKW SatPerKWeight + // feeFloor is the minimum fee rate the minFeeManager will enforce. + // When zero, FeePerKwFloor is used as the default. + feeFloor SatPerKWeight + // minFeeManager is used to keep track of the minimum fee, in sat/kw, // that we should enforce. This will be used as the default fee rate // for a transaction when the estimated fee rate is too low to allow @@ -368,7 +378,8 @@ type BitcoindEstimator struct { // in the occasion that the estimator has insufficient data, or returns zero // for a fee estimate. func NewBitcoindEstimator(rpcConfig rpcclient.ConnConfig, feeMode string, - fallBackFeeRate SatPerKWeight) (*BitcoindEstimator, error) { + fallBackFeeRate SatPerKWeight, + feeFloor SatPerKWeight) (*BitcoindEstimator, error) { rpcConfig.DisableConnectOnNew = true rpcConfig.DisableAutoReconnect = false @@ -385,6 +396,7 @@ func NewBitcoindEstimator(rpcConfig rpcclient.ConnConfig, feeMode string, return &BitcoindEstimator{ fallbackFeePerKW: fallBackFeeRate, + feeFloor: feeFloor, bitcoindConn: chainConn, feeMode: feeMode, filterManager: newFilterManager(fetchCb), @@ -402,6 +414,7 @@ func (b *BitcoindEstimator) Start() error { relayFeeManager, err := newMinFeeManager( defaultUpdateInterval, b.fetchMinMempoolFee, + b.feeFloor, ) if err != nil { return err @@ -670,12 +683,6 @@ func (s SparseConfFeeSource) parseResponse(r io.Reader) ( return WebAPIResponse{}, err } - if resp.MinRelayFeerate == 0 { - log.Errorf("No min relay fee rate available, using default %v", - FeePerKwFloor) - resp.MinRelayFeerate = FeePerKwFloor.FeePerKVByte() - } - return resp, nil } @@ -745,6 +752,11 @@ type WebAPIEstimator struct { feeByBlockTarget map[uint32]uint32 minRelayFeerate SatPerKVByte + // feeFloor is the minimum fee floor in sat/kvb. When the API reports a + // relay fee rate below this value (or reports none at all), feeFloor is + // used instead. Defaults to FeePerKwFloor.FeePerKVByte() when zero. + feeFloor SatPerKVByte + // noCache determines whether the web estimator should cache fee // estimates. noCache bool @@ -765,7 +777,8 @@ type WebAPIEstimator struct { // fallback default fee. The fees are updated whenever a new block is mined. func NewWebAPIEstimator(api WebAPIFeeSource, noCache bool, minFeeUpdateTimeout time.Duration, - maxFeeUpdateTimeout time.Duration) (*WebAPIEstimator, error) { + maxFeeUpdateTimeout time.Duration, + feeFloor SatPerKVByte) (*WebAPIEstimator, error) { if minFeeUpdateTimeout == 0 || maxFeeUpdateTimeout == 0 { return nil, fmt.Errorf("minFeeUpdateTimeout and " + @@ -781,6 +794,7 @@ func NewWebAPIEstimator(api WebAPIFeeSource, noCache bool, return &WebAPIEstimator{ apiSource: api, feeByBlockTarget: make(map[uint32]uint32), + feeFloor: feeFloor, noCache: noCache, quit: make(chan struct{}), minFeeUpdateTimeout: minFeeUpdateTimeout, @@ -825,8 +839,8 @@ func (w *WebAPIEstimator) EstimateFeePerKW(numBlocks uint32) ( // If the result is too low, then we'll clamp it to our current fee // floor. satPerKw := SatPerKVByte(feePerKb).FeePerKWeight() - if satPerKw < FeePerKwFloor { - satPerKw = FeePerKwFloor + if satPerKw < w.feeFloor.FeePerKWeight() { + satPerKw = w.feeFloor.FeePerKWeight() } log.Debugf("Web API returning %v sat/kw for conf target of %v", @@ -1014,9 +1028,16 @@ func (w *WebAPIEstimator) updateFeeEstimates() { return string(resp) })) + minRelayFeerate := resp.MinRelayFeerate + if minRelayFeerate == 0 || minRelayFeerate < w.feeFloor { + log.Debugf("API relay fee rate %v below configured floor %v, "+ + "using floor", minRelayFeerate, w.feeFloor) + minRelayFeerate = w.feeFloor + } + w.feesMtx.Lock() w.feeByBlockTarget = resp.FeeByBlockTarget - w.minRelayFeerate = resp.MinRelayFeerate + w.minRelayFeerate = minRelayFeerate w.feesMtx.Unlock() } diff --git a/lnwallet/chainfee/estimator_test.go b/lnwallet/chainfee/estimator_test.go index 3d355d5034d..ac1288dbf34 100644 --- a/lnwallet/chainfee/estimator_test.go +++ b/lnwallet/chainfee/estimator_test.go @@ -249,7 +249,7 @@ func TestWebAPIFeeEstimator(t *testing.T) { feeSource.On("GetFeeInfo").Return(resp, nil) estimator, _ := NewWebAPIEstimator( - feeSource, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, + feeSource, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0, ) // Test that when the estimator is not started, an error is returned. @@ -305,7 +305,7 @@ func TestGetCachedFee(t *testing.T) { // Create a dummy estimator without WebAPIFeeSource. estimator, _ := NewWebAPIEstimator( - nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, + nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0, ) // When the cache is empty, an error should be returned. @@ -381,7 +381,7 @@ func TestRandomFeeUpdateTimeout(t *testing.T) { ) estimator, _ := NewWebAPIEstimator( - nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, + nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0, ) for i := 0; i < 1000; i++ { @@ -401,7 +401,7 @@ func TestInvalidFeeUpdateTimeout(t *testing.T) { ) _, err := NewWebAPIEstimator( - nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, + nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0, ) require.Error(t, err, "NewWebAPIEstimator should return an error "+ "when minFeeUpdateTimeout > maxFeeUpdateTimeout") diff --git a/lnwallet/chainfee/minfeemanager.go b/lnwallet/chainfee/minfeemanager.go index 7eaef3e8b5a..1ee472831fa 100644 --- a/lnwallet/chainfee/minfeemanager.go +++ b/lnwallet/chainfee/minfeemanager.go @@ -13,6 +13,7 @@ const defaultUpdateInterval = 10 * time.Minute type minFeeManager struct { mu sync.Mutex minFeePerKW SatPerKWeight + feeFloor SatPerKWeight lastUpdatedTime time.Time minUpdateInterval time.Duration fetchFeeFunc fetchFee @@ -24,8 +25,11 @@ type fetchFee func() (SatPerKWeight, error) // newMinFeeManager creates a new minFeeManager and uses the // given fetchMinFee function to set the minFeePerKW of the minFeeManager. // This function requires the fetchMinFee function to succeed. +// +// feeFloor sets the minimum fee rate the manager will ever return. It defaults +// to FeePerKwFloor when zero is passed in. func newMinFeeManager(minUpdateInterval time.Duration, - fetchMinFee fetchFee) (*minFeeManager, error) { + fetchMinFee fetchFee, feeFloor SatPerKWeight) (*minFeeManager, error) { minFee, err := fetchMinFee() if err != nil { @@ -34,12 +38,13 @@ func newMinFeeManager(minUpdateInterval time.Duration, // Ensure that the minimum fee we use is always clamped by our fee // floor. - if minFee < FeePerKwFloor { - minFee = FeePerKwFloor + if minFee < feeFloor { + minFee = feeFloor } return &minFeeManager{ minFeePerKW: minFee, + feeFloor: feeFloor, lastUpdatedTime: time.Now(), minUpdateInterval: minUpdateInterval, fetchFeeFunc: fetchMinFee, @@ -70,8 +75,8 @@ func (m *minFeeManager) fetchMinFee() SatPerKWeight { // minimum fee rate we'll propose for transactions. However, if this // happens to be lower than our fee floor, we'll enforce that instead. m.minFeePerKW = newMinFee - if m.minFeePerKW < FeePerKwFloor { - m.minFeePerKW = FeePerKwFloor + if m.minFeePerKW < m.feeFloor { + m.minFeePerKW = m.feeFloor } m.lastUpdatedTime = time.Now() diff --git a/lnwallet/chainfee/minfeemanager_test.go b/lnwallet/chainfee/minfeemanager_test.go index b498e7962ea..01da7ff8faa 100644 --- a/lnwallet/chainfee/minfeemanager_test.go +++ b/lnwallet/chainfee/minfeemanager_test.go @@ -29,11 +29,12 @@ func TestMinFeeManager(t *testing.T) { minFee: FeePerKwFloor - 1, } - // Initialise the min fee manager. This should call the chain backend - // once. + // Initialise the min fee manager with the standard floor. This should + // call the chain backend once. feeManager, err := newMinFeeManager( 100*time.Millisecond, chainBackend.fetchFee, + FeePerKwFloor, ) require.NoError(t, err) require.Equal(t, 1, chainBackend.callCount) @@ -57,3 +58,60 @@ func TestMinFeeManager(t *testing.T) { require.Equal(t, SatPerKWeight(2000), minFee) require.Equal(t, 2, chainBackend.callCount) } + +// TestMinFeeManagerCustomFloor verifies that a custom feeFloor is respected, +// allowing fee rates below FeePerKwFloor when explicitly configured. +func TestMinFeeManagerCustomFloor(t *testing.T) { + t.Parallel() + + // A custom floor of 1 sat/kw — effectively no floor. + customFloor := SatPerKWeight(1) + + // Backend returns a fee rate well below the standard FeePerKwFloor. + backendFee := SatPerKWeight(100) // ~0.4 sat/vb + chainBackend := &mockChainBackend{minFee: backendFee} + + feeManager, err := newMinFeeManager( + 100*time.Millisecond, + chainBackend.fetchFee, + customFloor, + ) + require.NoError(t, err) + + // The manager should store and return the backend fee since it is + // above the custom floor, not clamped to FeePerKwFloor. + require.Equal(t, backendFee, feeManager.minFeePerKW) + + minFee := feeManager.fetchMinFee() + require.Equal(t, backendFee, minFee) +} + +// TestMinFeeManagerFloorClampsBackend verifies that when the backend returns a +// fee below the configured feeFloor, the floor is enforced. +func TestMinFeeManagerFloorClampsBackend(t *testing.T) { + t.Parallel() + + customFloor := SatPerKWeight(500) // custom floor: ~2 sat/vb + + // Backend returns something below the custom floor. + chainBackend := &mockChainBackend{minFee: SatPerKWeight(100)} + + feeManager, err := newMinFeeManager( + 100*time.Millisecond, + chainBackend.fetchFee, + customFloor, + ) + require.NoError(t, err) + + // The fee should be clamped to our custom floor. + require.Equal(t, customFloor, feeManager.minFeePerKW) + + // Fake time passing and have the backend return a fee above the floor. + feeManager.lastUpdatedTime = time.Now().Add(-200 * time.Millisecond) + feeManager.fetchFeeFunc = (&mockChainBackend{ + minFee: SatPerKWeight(800), + }).fetchFee + + minFee := feeManager.fetchMinFee() + require.Equal(t, SatPerKWeight(800), minFee) +} diff --git a/sample-lnd.conf b/sample-lnd.conf index 018741c1102..a21e8dddc11 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -619,6 +619,14 @@ ; The maximum interval in which fees will be updated from the specified fee URL. ; fee.max-update-timeout=20m +; Minimum fee rate floor in sat/vb used when estimating and enforcing on-chain +; fees. Lowering this below 1 sat/vb is only safe when your Bitcoin backend is +; configured with a matching minrelaytxfee and you control the path to miners +; (e.g. solo mining or a direct connection to a miner's mempool). Transactions +; below 1 sat/vb will not relay through the standard Bitcoin network. +; Default: 1 sat/vb (equivalent to 253 sat/kw) +; fee.min-relay-feerate=1 + [prometheus] diff --git a/sweep/walletsweep.go b/sweep/walletsweep.go index d814d3b3ba3..f5b4b7e5abc 100644 --- a/sweep/walletsweep.go +++ b/sweep/walletsweep.go @@ -112,23 +112,24 @@ func (f FeeEstimateInfo) Estimate(estimator chainfee.Estimator, case f.FeeRate != 0: feeRate = f.FeeRate - // Because the user can specify 1 sat/vByte on the RPC - // interface, which corresponds to 250 sat/kw, we need to bump - // that to the minimum "safe" fee rate which is 253 sat/kw. - if feeRate == chainfee.AbsoluteFeePerKwFloor { - log.Infof("Manual fee rate input of %d sat/kw is "+ - "too low, using %d sat/kw instead", feeRate, - chainfee.FeePerKwFloor) - - feeRate = chainfee.FeePerKwFloor + // When the user specifies exactly 1 sat/vb (250 sat/kw, i.e. + // AbsoluteFeePerKwFloor) and the configured relay floor is + // above that value, silently bump to the floor. This preserves + // the historical behaviour for default configurations while + // allowing sub-1 sat/vb inputs to pass through unchanged when + // the operator has explicitly lowered the floor. + if feeRate == chainfee.AbsoluteFeePerKwFloor && + estimator.RelayFeePerKW() > chainfee.AbsoluteFeePerKwFloor { + + feeRate = estimator.RelayFeePerKW() } } // Get the relay fee as the min fee rate. minFeeRate := estimator.RelayFeePerKW() - // If that bumped fee rate of at least 253 sat/kw is still lower than - // the relay fee rate, we return an error to let the user know. Note + // If the fee rate is still lower than the relay fee rate, return an + // error to let the user know. Note // that "Relay fee rate" may mean slightly different things depending // on the backend. For bitcoind, it is effectively max(relay fee, min // mempool fee).