loopd: account for channel reserves in loop out validation

Use each channel's local balance minus its reserve when simulating Loop
Out payment bandwidth, clamping the result at zero. This prevents swaps
from starting when the gross balance covers the amount and routing fee
but the spendable balance does not.

Add one-sat boundary tests and a bug-fix release note. Leave a TODO for
server-fee and prepay capacity because enforcing their maximum fee caps
could reject viable swaps whose actual routing fees are lower.
This commit is contained in:
Boris Nagaev 2026-08-10 17:30:38 -05:00
parent 1b81a06b42
commit 95e184a40f
No known key found for this signature in database
3 changed files with 72 additions and 6 deletions

View file

@ -6,6 +6,10 @@
#### Bug Fixes
* Loop Out requests now account for channel reserves when checking outbound
capacity, preventing swaps from starting when their off-chain payment cannot
be funded.
* Taproot Asset Loop Out handling now validates RFQ timeouts and asset rates,
keeps cached asset-name lookups responsive during slow `tapd` queries, and
closes `tapd` connections cleanly during shutdown and startup failures.

View file

@ -3013,6 +3013,13 @@ func validateLoopOutRequest(ctx context.Context, lnd lndclient.LightningClient,
// the amount requested, the maximum possible routing fees,
// the available channel set and the fact that equal splitting is
// used for MPP.
//
// TODO: Also account for the quoted server fee and the concurrent prepay
// payment. The CLI and autoloop obtain the fee and prepay amounts from a
// quote, but this RPC only carries maximum limits, which direct callers may
// set higher than the quoted amounts. Treating those limits as exact can
// reject a viable swap, while the actual routing fees are only known when
// the invoices are paid.
requiredBalance := btcutil.Amount(req.Amt + req.MaxSwapRoutingFee)
isRoutable, _ := hasBandwidth(activeChannelSet, requiredBalance,
int(maxParts))
@ -3047,11 +3054,24 @@ func hasBandwidth(channels []lndclient.ChannelInfo, amt btcutil.Amount,
localBalances := make([]btcutil.Amount, len(channels))
var totalBandwidth btcutil.Amount
for i, channel := range channels {
tracef("Channel %v: local=%v remote=%v", channel.ChannelID,
channel.LocalBalance, channel.RemoteBalance)
localBalance := channel.LocalBalance
var reserve btcutil.Amount
if channel.LocalConstraints != nil {
reserve = channel.LocalConstraints.Reserve
}
localBalances[i] = channel.LocalBalance
totalBandwidth += channel.LocalBalance
if reserve >= localBalance {
localBalance = 0
} else {
localBalance -= reserve
}
tracef("Channel %v: local=%v reserve=%v available=%v "+
"remote=%v", channel.ChannelID, channel.LocalBalance,
reserve, localBalance, channel.RemoteBalance)
localBalances[i] = localBalance
totalBandwidth += localBalance
}
tracef("Total bandwidth: %v", totalBandwidth)
@ -3073,8 +3093,6 @@ func hasBandwidth(channels []lndclient.ChannelInfo, amt btcutil.Amount,
paid := false
for i := range len(localBalances) {
// TODO(hieblmi): Consider channel reserves because the
// channel can't send its full local balance.
if localBalances[i] >= split {
tracef("len(shards)=%v: Local channel "+
"balance %v can pay %v sats",

View file

@ -1378,6 +1378,50 @@ func TestValidateLoopOutRequest(t *testing.T) {
err: errBalanceTooLow,
expectedTarget: 0,
},
{
name: "channel reserve leaves balance one sat short",
chain: chaincfg.MainNetParams,
destAddr: mainnetAddr,
label: "label ok",
confTarget: 2,
channels: []lndclient.ChannelInfo{
{
Active: true,
ChannelID: chanID2.ToUint64(),
LocalBalance: 10100,
LocalConstraints: &lndclient.ChannelConstraints{
Reserve: 100,
},
},
},
amount: 10000,
maxRoutingFee: 1,
maxParts: 1,
err: errBalanceTooLow,
expectedTarget: 0,
},
{
name: "channel reserve leaves exact balance",
chain: chaincfg.MainNetParams,
destAddr: mainnetAddr,
label: "label ok",
confTarget: 2,
channels: []lndclient.ChannelInfo{
{
Active: true,
ChannelID: chanID2.ToUint64(),
LocalBalance: 10101,
LocalConstraints: &lndclient.ChannelConstraints{
Reserve: 100,
},
},
},
amount: 10000,
maxRoutingFee: 1,
maxParts: 1,
err: nil,
expectedTarget: 2,
},
{
name: "can split between channels",
chain: chaincfg.MainNetParams,