Merge pull request #11277 from GeorgeTsagk/maxfeeratio-ceiling
What changed, and why it matters
This change loosens a safety cap on Bitcoin transaction fees for a specific LND wallet RPC. Previously, callers could not allow fees larger than the transaction's total output value. Now they can opt in to a ratio up to 5x the output value, with a hard ceiling and an audit log warning. The intended use is legitimate: sweeping or re-creating tiny 'asset carrier' outputs that hold only about 1000 satoshis of Bitcoin but carry valuable off-chain assets. Without the higher cap, those operations could be blocked when on-chain fees spike. The change is defensive rather than a vulnerability fix, but it does increase the damage a compromised caller or malicious user with RPC access could do by overpaying fees.
Treat this as a configuration/risk change, not an urgent vulnerability patch. Operators should monitor FundPsbt calls with maxFeeRatio > 1.0 via the new warning log, restrict RPC access to trusted callers, and ensure wallet policies require explicit approval for high fee ratios. Review whether 5.0 is the appropriate ceiling for deployed use cases.
Security signals we found
Relaxation of a fee-ratio safety bound from 1.0 to 5.0
New audit log warning when caller opts into ratio > 1.0
Hard ceiling at 5.0 to catch misconfiguration
Default maxFeeRatio remains 0.2 for internal flows
Only the FundPsbt RPC path exposes the opt-in
Evidence from the diff
The commit raises the maxFeeRatio sanity-check ceiling in lnwallet/chanfunding/coin_select from 1.0 to 5.0, while keeping the default at 0.2. It only affects the FundPsbt RPC path in lnrpc/walletrpc/walletkit_server.go, where callers can explicitly supply a maxFeeRatio. A warning log line is added when a caller requests a ratio above 1.0. Tests are updated to verify that ratios above 1.0 are accepted, ratios above 5.0 are rejected, and the default still rejects high-fee transactions. The change is framed as enabling sweeps of small asset-bearing (e.g., Taproot Assets) outputs whose BTC face value is near dust but whose fees can exceed that value.
Changed components
lnwallet/chanfunding/coin_select.golnrpc/walletrpc/walletkit_server.goFundPsbt RPCsanityCheckFee helperInspect captured patch +119 / −7
### lnrpc/walletrpc/walletkit_server.go
@@ -1887,6 +1887,15 @@ func (w *WalletKit) FundPsbt(_ context.Context,
maxFeeRatio = req.MaxFeeRatio
}
+ // A ratio above 1.0 means the caller explicitly allows fees
+ // exceeding the funded outputs' value (legitimate for sweeps
+ // of small asset-bearing outputs). Make that auditable.
+ if maxFeeRatio > 1.0 {
+ log.Warnf("FundPsbt: caller requested max fee ratio "+
+ "%.2f, allowing fees above the total output "+
+ "value", maxFeeRatio)
+ }
+
// Run the actual funding process now, using the channel funding
// coin selection algorithm.
return w.fundPsbtCoinSelect(
### lnrpc/walletrpc/walletkit_server_test.go
@@ -760,7 +760,7 @@ func TestFundPsbtCoinSelect(t *testing.T) {
expectedFee: calcFee(0, 1, 0, 1, 0),
expectedContainedErrStr: "maxFeeRatio must be between 0.00 " +
- "and 1.00 got -0.20",
+ "and 5.00, got -0.20",
}, {
name: "1 p2wpkh utxo, existing p2wkh change, big fee ratio",
utxos: []*lnwallet.Utxo{
### lnwallet/chanfunding/coin_select.go
@@ -62,6 +62,16 @@ const (
// DefaultMaxFeeRatio is the default fee to total amount of outputs
// ratio that is used to sanity check the fees of a transaction.
DefaultMaxFeeRatio float64 = 0.2
+
+ // maxAllowedFeeRatio is the hard ceiling on any caller-supplied
+ // maxFeeRatio. Ratios above 1.0 are legitimate for sweeps of small
+ // asset-bearing outputs: re-anchoring a ~1000 sat asset carrier
+ // output can cost several times its BTC value in fees at moderate
+ // fee rates. That known use case needs a ratio of up to 5.0, so we
+ // cap at exactly that; anything beyond it is treated as a
+ // misconfiguration. The ceiling can be revisited if another
+ // demonstrated use case appears.
+ maxAllowedFeeRatio float64 = 5.0
)
// selectInputs selects a slice of inputs necessary to meet the specified
@@ -150,10 +160,16 @@ func calculateFees(utxos []wallet.Coin, feeRate chainfee.SatPerKWeight,
// sanityCheckFee checks if the specified fee amounts to what the provided ratio
// allows.
func sanityCheckFee(totalOut, fee btcutil.Amount, maxFeeRatio float64) error {
- // Sanity check the maxFeeRatio itself.
- if maxFeeRatio <= 0.00 || maxFeeRatio > 1.00 {
- return fmt.Errorf("maxFeeRatio must be between 0.00 and 1.00 "+
- "got %.2f", maxFeeRatio)
+ // Sanity check the maxFeeRatio itself. Ratios above 1.0 are allowed
+ // but only ever reach this code when an RPC caller explicitly
+ // requests one: every internal funding flow passes
+ // DefaultMaxFeeRatio. The opt-in exists because some flows (e.g.
+ // spending small asset-bearing outputs whose BTC value is near dust)
+ // legitimately produce fee-to-output ratios that exceed 100%. A hard
+ // ceiling still catches nonsensical values.
+ if maxFeeRatio <= 0.00 || maxFeeRatio > maxAllowedFeeRatio {
+ return fmt.Errorf("maxFeeRatio must be between 0.00 and "+
+ "%.2f, got %.2f", maxAllowedFeeRatio, maxFeeRatio)
}
maxFee := btcutil.Amount(float64(totalOut) * maxFeeRatio)
### lnwallet/chanfunding/coin_select_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/btcsuite/btcd/btcutil/v2"
+ "github.com/btcsuite/btcd/txscript/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/btcsuite/btcwallet/wallet"
"github.com/lightningnetwork/lnd/input"
@@ -435,9 +436,48 @@ func TestCalculateChangeAmount(t *testing.T) {
feeNoChange: 10,
feeWithChange: 45,
dustLimit: 5,
- maxFeeRatio: 3.14,
+ maxFeeRatio: -0.1,
- expectErr: "maxFeeRatio must be between 0.00 and 1.00",
+ expectErr: "maxFeeRatio must be between 0.00 and 5.00",
+ }, {
+ // A ratio above the hard ceiling is rejected even as an
+ // explicit opt-in: the ceiling tracks the highest presently
+ // demonstrated requirement (see maxAllowedFeeRatio).
+ name: "max fee ratio above hard ceiling",
+ totalInputAmt: 100,
+ requiredAmt: 50,
+ feeNoChange: 10,
+ feeWithChange: 45,
+ dustLimit: 5,
+ maxFeeRatio: 5.01,
+
+ expectErr: "maxFeeRatio must be between 0.00 and 5.00",
+ }, {
+ // A ratio above 1.0 is a valid opt-in: sweeps of small
+ // asset-bearing outputs (whose value is mostly carried
+ // off-chain) legitimately pay more in fees than the total
+ // output value.
+ name: "fee ratio above one",
+ totalInputAmt: 500,
+ requiredAmt: 100,
+ feeNoChange: 150,
+ feeWithChange: 350,
+ dustLimit: 5,
+ maxFeeRatio: 5.0,
+
+ expectChangeAmt: 50,
+ }, {
+ // The same fee profile must be rejected under the default
+ // ratio, proving the opt-in is what allows it.
+ name: "fee ratio above one requires opt-in",
+ totalInputAmt: 500,
+ requiredAmt: 100,
+ feeNoChange: 150,
+ feeWithChange: 350,
+ dustLimit: 5,
+ maxFeeRatio: DefaultMaxFeeRatio,
+
+ expectErr: "exceeds max fee",
}, {
name: "invalid usage of function",
feeNoChange: 5,
@@ -467,6 +507,53 @@ func TestCalculateChangeAmount(t *testing.T) {
}
}
+// TestNearDustReAnchorFeeRatio pins down the concrete use case that requires
+// a maxFeeRatio above 1.0: re-anchoring a small asset-bearing output. Asset
+// carrier outputs hold a fixed ~1000 sats of BTC while their real value lives
+// off-chain, so the fee of the transaction that re-creates such an output
+// exceeds the total BTC output value already at moderate fee rates. The test
+// derives the fee from a realistic transaction shape and asserts that the
+// flow is rejected under both the default ratio and a ratio of exactly 1.0,
+// but accepted under the 5.0 ceiling.
+func TestNearDustReAnchorFeeRatio(t *testing.T) {
+ t.Parallel()
+
+ // The BTC value of an asset carrier output (tapd's
+ // tapsend.DummyAmtSats): this is the total output value of the
+ // re-anchor transaction template before the wallet attaches a fee
+ // input.
+ const anchorOutputValue = btcutil.Amount(1_000)
+
+ // The re-anchor transaction spends the asset carrier input plus one
+ // wallet input for fees, and re-creates the carrier output. Both
+ // inputs and the output are P2TR.
+ var est input.TxWeightEstimator
+ est.AddTaprootKeySpendInput(txscript.SigHashDefault)
+ est.AddTaprootKeySpendInput(txscript.SigHashDefault)
+ est.AddP2TROutput()
+
+ // 15 sat/vB is a moderate fee rate, nowhere near a fee spike.
+ feeRate := chainfee.SatPerKVByte(15_000).FeePerKWeight()
+ fee := feeRate.FeeForWeight(est.Weight())
+
+ // The fee alone already exceeds the total output value, so both the
+ // default ratio and a full 1.0 ratio must reject the transaction.
+ require.Greater(t, fee, anchorOutputValue)
+ require.ErrorContains(
+ t, sanityCheckFee(anchorOutputValue, fee, DefaultMaxFeeRatio),
+ "exceeds max fee",
+ )
+ require.ErrorContains(
+ t, sanityCheckFee(anchorOutputValue, fee, 1.0),
+ "exceeds max fee",
+ )
+
+ // The opt-in ceiling of 5.0 covers the flow.
+ require.NoError(t, sanityCheckFee(
+ anchorOutputValue, fee, maxAllowedFeeRatio,
+ ))
+}
+
// TestCoinSelectSubtractFees tests that we pick coins adding up to the
// expected amount when creating a funding transaction, and that a change
// output is created only when necessary.Why this scored 36/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.