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,