From 95e184a40fa3d61adc8f2074232e0002aaaff99e Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 10 Aug 2026 17:30:38 -0500 Subject: [PATCH] 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. --- docs/release-notes/release-notes-next.md | 4 +++ loopd/swapclient_server.go | 30 ++++++++++++---- loopd/swapclient_server_test.go | 44 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index f0ca7972..fb302d1e 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -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. diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 28b4f722..a0559d41 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -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", diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 03f3d95a..1171389c 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -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,