Skip to content

Commit 532d237

Browse files
committed
chainfee: make relay fee floor configurable via --fee.min-relay-feerate
The minimum relay fee rate is currently hardcoded to FeePerKwFloor (253 sat/kw, ~1 sat/vb). This prevents operators running nodes that connect to a Bitcoin backend with a lower minrelaytxfee from opening channels or sweeping funds at sub-1 sat/vb rates. This commit introduces a new --fee.min-relay-feerate option (in sat/vb) that lets operators lower the floor when their setup permits it. The default remains 1 sat/vb, preserving existing behaviour for all nodes. Changes: - lnwallet/chainfee/minfeemanager: accept a feeFloor parameter instead of always clamping to the package-level FeePerKwFloor constant - lnwallet/chainfee/estimator: thread feeFloor through BtcdEstimator, BitcoindEstimator, and WebAPIEstimator constructors; use the configured floor in EstimateFeePerKW clamping and relay fee fallback - sweep/walletsweep: remove the unconditional 250→253 sat/kw bump; the downstream minFeeRate check already enforces the relay floor - chainreg/chainregistry: pass cfg.Fee.FeeFloorKW() to all estimator constructors and to the static estimator - lncfg/fee: add MinRelayFeeRate field with Validate() and FeeFloorKW() helpers - sample-lnd.conf: document the new fee.min-relay-feerate option WARNING: lowering this value below 1 sat/vb means your node will create transactions that standard Bitcoin nodes will not relay. Only use this option if your backend has a matching minrelaytxfee and you have a direct path to a miner's mempool. Signed-off-by: Kilombino <kilombino@proton.me>
1 parent 746ed3c commit 532d237

9 files changed

Lines changed: 163 additions & 40 deletions

File tree

chainreg/chainregistry.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) {
235235
MinHtlcIn: cfg.Bitcoin.MinHTLCIn,
236236
FeeEstimator: chainfee.NewStaticEstimator(
237237
DefaultBitcoinStaticFeePerKW,
238-
DefaultBitcoinStaticMinRelayFeeRate,
238+
cfg.Fee.FeeFloorKW(),
239239
),
240240
}
241241

@@ -409,6 +409,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) {
409409
cc.FeeEstimator, err = chainfee.NewBitcoindEstimator(
410410
*rpcConfig, bitcoindMode.EstimateMode,
411411
fallBackFeeRate.FeePerKWeight(),
412+
cfg.Fee.FeeFloorKW(),
412413
)
413414
if err != nil {
414415
return nil, nil, err
@@ -672,6 +673,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) {
672673
fallBackFeeRate := chainfee.SatPerKVByte(25 * 1000)
673674
cc.FeeEstimator, err = chainfee.NewBtcdEstimator(
674675
*rpcConfig, fallBackFeeRate.FeePerKWeight(),
676+
cfg.Fee.FeeFloorKW(),
675677
)
676678
if err != nil {
677679
return nil, nil, err
@@ -729,6 +731,7 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) {
729731
!cacheFees,
730732
cfg.Fee.MinUpdateTimeout,
731733
cfg.Fee.MaxUpdateTimeout,
734+
cfg.Fee.MinRelayFeeRate.FeePerKVByte(),
732735
)
733736
if err != nil {
734737
return nil, nil, err

config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ func DefaultConfig() Config {
676676
Fee: &lncfg.Fee{
677677
MinUpdateTimeout: lncfg.DefaultMinUpdateTimeout,
678678
MaxUpdateTimeout: lncfg.DefaultMaxUpdateTimeout,
679+
MinRelayFeeRate: lncfg.DefaultMinRelayFeeRate,
679680
},
680681

681682
SubRPCServers: &subRPCServerConfigs{

lncfg/fee.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package lncfg
22

3-
import "time"
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
8+
)
49

510
// DefaultMinUpdateTimeout represents the minimum interval in which a
611
// WebAPIEstimator will request fresh fees from its API.
@@ -10,11 +15,32 @@ const DefaultMinUpdateTimeout = 5 * time.Minute
1015
// WebAPIEstimator will request fresh fees from its API.
1116
const DefaultMaxUpdateTimeout = 20 * time.Minute
1217

18+
// DefaultMinRelayFeeRate is the default minimum relay fee rate floor in
19+
// sat/vb. 1 sat/vb equals 250 sat/kw; the relay floor is set to 1 sat/vb
20+
// so that the effective floor remains FeePerKwFloor (253 sat/kw) after
21+
// the minFeeManager rounds up from the backend's reported value.
22+
const DefaultMinRelayFeeRate chainfee.SatPerVByte = 1
23+
1324
// Fee holds the configuration options for fee estimation.
1425
//
1526
//nolint:ll
1627
type Fee struct {
17-
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."`
18-
MinUpdateTimeout time.Duration `long:"min-update-timeout" description:"The minimum interval in which fees will be updated from the specified fee URL."`
19-
MaxUpdateTimeout time.Duration `long:"max-update-timeout" description:"The maximum interval in which fees will be updated from the specified fee URL."`
28+
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."`
29+
MinUpdateTimeout time.Duration `long:"min-update-timeout" description:"The minimum interval in which fees will be updated from the specified fee URL."`
30+
MaxUpdateTimeout time.Duration `long:"max-update-timeout" description:"The maximum interval in which fees will be updated from the specified fee URL."`
31+
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)."`
32+
}
33+
34+
// Validate checks the fee configuration for invalid values.
35+
func (f *Fee) Validate() error {
36+
if f.MinRelayFeeRate < 0 {
37+
return fmt.Errorf("fee.min-relay-feerate must be >= 0")
38+
}
39+
40+
return nil
41+
}
42+
43+
// FeeFloorKW returns the configured minimum relay fee rate as sat/kw.
44+
func (f *Fee) FeeFloorKW() chainfee.SatPerKWeight {
45+
return f.MinRelayFeeRate.FeePerKWeight()
2046
}

lnwallet/chainfee/estimator.go

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ type BtcdEstimator struct {
147147
// produce fee estimates.
148148
fallbackFeePerKW SatPerKWeight
149149

150+
// feeFloor is the minimum fee rate the minFeeManager will enforce.
151+
// When zero, FeePerKwFloor is used as the default.
152+
feeFloor SatPerKWeight
153+
150154
// minFeeManager is used to query the current minimum fee, in sat/kw,
151155
// that we should enforce. This will be used to determine fee rate for
152156
// a transaction when the estimated fee rate is too low to allow the
@@ -167,7 +171,8 @@ type BtcdEstimator struct {
167171
// the occasion that the estimator has insufficient data, or returns zero for a
168172
// fee estimate.
169173
func NewBtcdEstimator(rpcConfig rpcclient.ConnConfig,
170-
fallBackFeeRate SatPerKWeight) (*BtcdEstimator, error) {
174+
fallBackFeeRate SatPerKWeight,
175+
feeFloor SatPerKWeight) (*BtcdEstimator, error) {
171176

172177
rpcConfig.DisableConnectOnNew = true
173178
rpcConfig.DisableAutoReconnect = false
@@ -182,6 +187,7 @@ func NewBtcdEstimator(rpcConfig rpcclient.ConnConfig,
182187

183188
return &BtcdEstimator{
184189
fallbackFeePerKW: fallBackFeeRate,
190+
feeFloor: feeFloor,
185191
btcdConn: chainConn,
186192
filterManager: newFilterManager(fetchCb),
187193
}, nil
@@ -200,7 +206,7 @@ func (b *BtcdEstimator) Start() error {
200206
// can initialise the minimum relay fee manager which queries the
201207
// chain backend for the minimum relay fee on construction.
202208
minRelayFeeManager, err := newMinFeeManager(
203-
defaultUpdateInterval, b.fetchMinRelayFee,
209+
defaultUpdateInterval, b.fetchMinRelayFee, b.feeFloor,
204210
)
205211
if err != nil {
206212
return err
@@ -341,6 +347,10 @@ type BitcoindEstimator struct {
341347
// produce fee estimates.
342348
fallbackFeePerKW SatPerKWeight
343349

350+
// feeFloor is the minimum fee rate the minFeeManager will enforce.
351+
// When zero, FeePerKwFloor is used as the default.
352+
feeFloor SatPerKWeight
353+
344354
// minFeeManager is used to keep track of the minimum fee, in sat/kw,
345355
// that we should enforce. This will be used as the default fee rate
346356
// for a transaction when the estimated fee rate is too low to allow
@@ -368,7 +378,8 @@ type BitcoindEstimator struct {
368378
// in the occasion that the estimator has insufficient data, or returns zero
369379
// for a fee estimate.
370380
func NewBitcoindEstimator(rpcConfig rpcclient.ConnConfig, feeMode string,
371-
fallBackFeeRate SatPerKWeight) (*BitcoindEstimator, error) {
381+
fallBackFeeRate SatPerKWeight,
382+
feeFloor SatPerKWeight) (*BitcoindEstimator, error) {
372383

373384
rpcConfig.DisableConnectOnNew = true
374385
rpcConfig.DisableAutoReconnect = false
@@ -385,6 +396,7 @@ func NewBitcoindEstimator(rpcConfig rpcclient.ConnConfig, feeMode string,
385396

386397
return &BitcoindEstimator{
387398
fallbackFeePerKW: fallBackFeeRate,
399+
feeFloor: feeFloor,
388400
bitcoindConn: chainConn,
389401
feeMode: feeMode,
390402
filterManager: newFilterManager(fetchCb),
@@ -402,6 +414,7 @@ func (b *BitcoindEstimator) Start() error {
402414
relayFeeManager, err := newMinFeeManager(
403415
defaultUpdateInterval,
404416
b.fetchMinMempoolFee,
417+
b.feeFloor,
405418
)
406419
if err != nil {
407420
return err
@@ -670,12 +683,6 @@ func (s SparseConfFeeSource) parseResponse(r io.Reader) (
670683
return WebAPIResponse{}, err
671684
}
672685

673-
if resp.MinRelayFeerate == 0 {
674-
log.Errorf("No min relay fee rate available, using default %v",
675-
FeePerKwFloor)
676-
resp.MinRelayFeerate = FeePerKwFloor.FeePerKVByte()
677-
}
678-
679686
return resp, nil
680687
}
681688

@@ -745,6 +752,11 @@ type WebAPIEstimator struct {
745752
feeByBlockTarget map[uint32]uint32
746753
minRelayFeerate SatPerKVByte
747754

755+
// feeFloor is the minimum fee floor in sat/kvb. When the API reports a
756+
// relay fee rate below this value (or reports none at all), feeFloor is
757+
// used instead. Defaults to FeePerKwFloor.FeePerKVByte() when zero.
758+
feeFloor SatPerKVByte
759+
748760
// noCache determines whether the web estimator should cache fee
749761
// estimates.
750762
noCache bool
@@ -765,7 +777,8 @@ type WebAPIEstimator struct {
765777
// fallback default fee. The fees are updated whenever a new block is mined.
766778
func NewWebAPIEstimator(api WebAPIFeeSource, noCache bool,
767779
minFeeUpdateTimeout time.Duration,
768-
maxFeeUpdateTimeout time.Duration) (*WebAPIEstimator, error) {
780+
maxFeeUpdateTimeout time.Duration,
781+
feeFloor SatPerKVByte) (*WebAPIEstimator, error) {
769782

770783
if minFeeUpdateTimeout == 0 || maxFeeUpdateTimeout == 0 {
771784
return nil, fmt.Errorf("minFeeUpdateTimeout and " +
@@ -781,6 +794,7 @@ func NewWebAPIEstimator(api WebAPIFeeSource, noCache bool,
781794
return &WebAPIEstimator{
782795
apiSource: api,
783796
feeByBlockTarget: make(map[uint32]uint32),
797+
feeFloor: feeFloor,
784798
noCache: noCache,
785799
quit: make(chan struct{}),
786800
minFeeUpdateTimeout: minFeeUpdateTimeout,
@@ -825,8 +839,8 @@ func (w *WebAPIEstimator) EstimateFeePerKW(numBlocks uint32) (
825839
// If the result is too low, then we'll clamp it to our current fee
826840
// floor.
827841
satPerKw := SatPerKVByte(feePerKb).FeePerKWeight()
828-
if satPerKw < FeePerKwFloor {
829-
satPerKw = FeePerKwFloor
842+
if satPerKw < w.feeFloor.FeePerKWeight() {
843+
satPerKw = w.feeFloor.FeePerKWeight()
830844
}
831845

832846
log.Debugf("Web API returning %v sat/kw for conf target of %v",
@@ -1014,9 +1028,16 @@ func (w *WebAPIEstimator) updateFeeEstimates() {
10141028
return string(resp)
10151029
}))
10161030

1031+
minRelayFeerate := resp.MinRelayFeerate
1032+
if minRelayFeerate == 0 || minRelayFeerate < w.feeFloor {
1033+
log.Debugf("API relay fee rate %v below configured floor %v, "+
1034+
"using floor", minRelayFeerate, w.feeFloor)
1035+
minRelayFeerate = w.feeFloor
1036+
}
1037+
10171038
w.feesMtx.Lock()
10181039
w.feeByBlockTarget = resp.FeeByBlockTarget
1019-
w.minRelayFeerate = resp.MinRelayFeerate
1040+
w.minRelayFeerate = minRelayFeerate
10201041
w.feesMtx.Unlock()
10211042
}
10221043

lnwallet/chainfee/estimator_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ func TestWebAPIFeeEstimator(t *testing.T) {
249249
feeSource.On("GetFeeInfo").Return(resp, nil)
250250

251251
estimator, _ := NewWebAPIEstimator(
252-
feeSource, false, minFeeUpdateTimeout, maxFeeUpdateTimeout,
252+
feeSource, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0,
253253
)
254254

255255
// Test that when the estimator is not started, an error is returned.
@@ -305,7 +305,7 @@ func TestGetCachedFee(t *testing.T) {
305305

306306
// Create a dummy estimator without WebAPIFeeSource.
307307
estimator, _ := NewWebAPIEstimator(
308-
nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout,
308+
nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0,
309309
)
310310

311311
// When the cache is empty, an error should be returned.
@@ -381,7 +381,7 @@ func TestRandomFeeUpdateTimeout(t *testing.T) {
381381
)
382382

383383
estimator, _ := NewWebAPIEstimator(
384-
nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout,
384+
nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0,
385385
)
386386

387387
for i := 0; i < 1000; i++ {
@@ -401,7 +401,7 @@ func TestInvalidFeeUpdateTimeout(t *testing.T) {
401401
)
402402

403403
_, err := NewWebAPIEstimator(
404-
nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout,
404+
nil, false, minFeeUpdateTimeout, maxFeeUpdateTimeout, 0,
405405
)
406406
require.Error(t, err, "NewWebAPIEstimator should return an error "+
407407
"when minFeeUpdateTimeout > maxFeeUpdateTimeout")

lnwallet/chainfee/minfeemanager.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const defaultUpdateInterval = 10 * time.Minute
1313
type minFeeManager struct {
1414
mu sync.Mutex
1515
minFeePerKW SatPerKWeight
16+
feeFloor SatPerKWeight
1617
lastUpdatedTime time.Time
1718
minUpdateInterval time.Duration
1819
fetchFeeFunc fetchFee
@@ -24,8 +25,11 @@ type fetchFee func() (SatPerKWeight, error)
2425
// newMinFeeManager creates a new minFeeManager and uses the
2526
// given fetchMinFee function to set the minFeePerKW of the minFeeManager.
2627
// This function requires the fetchMinFee function to succeed.
28+
//
29+
// feeFloor sets the minimum fee rate the manager will ever return. It defaults
30+
// to FeePerKwFloor when zero is passed in.
2731
func newMinFeeManager(minUpdateInterval time.Duration,
28-
fetchMinFee fetchFee) (*minFeeManager, error) {
32+
fetchMinFee fetchFee, feeFloor SatPerKWeight) (*minFeeManager, error) {
2933

3034
minFee, err := fetchMinFee()
3135
if err != nil {
@@ -34,12 +38,13 @@ func newMinFeeManager(minUpdateInterval time.Duration,
3438

3539
// Ensure that the minimum fee we use is always clamped by our fee
3640
// floor.
37-
if minFee < FeePerKwFloor {
38-
minFee = FeePerKwFloor
41+
if minFee < feeFloor {
42+
minFee = feeFloor
3943
}
4044

4145
return &minFeeManager{
4246
minFeePerKW: minFee,
47+
feeFloor: feeFloor,
4348
lastUpdatedTime: time.Now(),
4449
minUpdateInterval: minUpdateInterval,
4550
fetchFeeFunc: fetchMinFee,
@@ -70,8 +75,8 @@ func (m *minFeeManager) fetchMinFee() SatPerKWeight {
7075
// minimum fee rate we'll propose for transactions. However, if this
7176
// happens to be lower than our fee floor, we'll enforce that instead.
7277
m.minFeePerKW = newMinFee
73-
if m.minFeePerKW < FeePerKwFloor {
74-
m.minFeePerKW = FeePerKwFloor
78+
if m.minFeePerKW < m.feeFloor {
79+
m.minFeePerKW = m.feeFloor
7580
}
7681
m.lastUpdatedTime = time.Now()
7782

lnwallet/chainfee/minfeemanager_test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ func TestMinFeeManager(t *testing.T) {
2929
minFee: FeePerKwFloor - 1,
3030
}
3131

32-
// Initialise the min fee manager. This should call the chain backend
33-
// once.
32+
// Initialise the min fee manager with the standard floor. This should
33+
// call the chain backend once.
3434
feeManager, err := newMinFeeManager(
3535
100*time.Millisecond,
3636
chainBackend.fetchFee,
37+
FeePerKwFloor,
3738
)
3839
require.NoError(t, err)
3940
require.Equal(t, 1, chainBackend.callCount)
@@ -57,3 +58,60 @@ func TestMinFeeManager(t *testing.T) {
5758
require.Equal(t, SatPerKWeight(2000), minFee)
5859
require.Equal(t, 2, chainBackend.callCount)
5960
}
61+
62+
// TestMinFeeManagerCustomFloor verifies that a custom feeFloor is respected,
63+
// allowing fee rates below FeePerKwFloor when explicitly configured.
64+
func TestMinFeeManagerCustomFloor(t *testing.T) {
65+
t.Parallel()
66+
67+
// A custom floor of 1 sat/kw — effectively no floor.
68+
customFloor := SatPerKWeight(1)
69+
70+
// Backend returns a fee rate well below the standard FeePerKwFloor.
71+
backendFee := SatPerKWeight(100) // ~0.4 sat/vb
72+
chainBackend := &mockChainBackend{minFee: backendFee}
73+
74+
feeManager, err := newMinFeeManager(
75+
100*time.Millisecond,
76+
chainBackend.fetchFee,
77+
customFloor,
78+
)
79+
require.NoError(t, err)
80+
81+
// The manager should store and return the backend fee since it is
82+
// above the custom floor, not clamped to FeePerKwFloor.
83+
require.Equal(t, backendFee, feeManager.minFeePerKW)
84+
85+
minFee := feeManager.fetchMinFee()
86+
require.Equal(t, backendFee, minFee)
87+
}
88+
89+
// TestMinFeeManagerFloorClampsBackend verifies that when the backend returns a
90+
// fee below the configured feeFloor, the floor is enforced.
91+
func TestMinFeeManagerFloorClampsBackend(t *testing.T) {
92+
t.Parallel()
93+
94+
customFloor := SatPerKWeight(500) // custom floor: ~2 sat/vb
95+
96+
// Backend returns something below the custom floor.
97+
chainBackend := &mockChainBackend{minFee: SatPerKWeight(100)}
98+
99+
feeManager, err := newMinFeeManager(
100+
100*time.Millisecond,
101+
chainBackend.fetchFee,
102+
customFloor,
103+
)
104+
require.NoError(t, err)
105+
106+
// The fee should be clamped to our custom floor.
107+
require.Equal(t, customFloor, feeManager.minFeePerKW)
108+
109+
// Fake time passing and have the backend return a fee above the floor.
110+
feeManager.lastUpdatedTime = time.Now().Add(-200 * time.Millisecond)
111+
feeManager.fetchFeeFunc = (&mockChainBackend{
112+
minFee: SatPerKWeight(800),
113+
}).fetchFee
114+
115+
minFee := feeManager.fetchMinFee()
116+
require.Equal(t, SatPerKWeight(800), minFee)
117+
}

0 commit comments

Comments
 (0)