From e8ba2263ec940a4b41f1966c39306a43ac464829 Mon Sep 17 00:00:00 2001 From: Hakuna Date: Mon, 21 Sep 2026 19:15:51 +0200 Subject: [PATCH 1/6] feat(loop): add multi-channel outgoing support for Loop Out - Support comma-separated channel lists (--channel id1,id2...) natively supported by loopd/litloop out. - Enable liquidity pooling across up to --max-channels candidate channels when target swap amount exceeds single channel capacity. - Add Spacebar multi-select toggling and select-all [a] to interactive terminal UI, with real-time combined swap size and aggregate PPM calculation. - Add greedy multi-channel batching fallback for non-interactive/headless environments. - Compute cumulative routing fee budget with configured leeway across all selected outgoing channels to prevent off-chain routing failures. - Update config.ini.example with max_channels default (3) and expand documentation in Other/swap_out-loop.md. - Add full unit test coverage for multi-channel pooling, argument parsing, non-TTY greedy batching, and command construction (28/28 tests passing). --- Other/swap_out-loop.md | 21 ++- Other/swap_out-loop.py | 213 ++++++++++++++++++++++++------ config.ini.example | 2 + tests/Other/test_swap_out_loop.py | 144 ++++++++++++++++++++ 4 files changed, 338 insertions(+), 42 deletions(-) diff --git a/Other/swap_out-loop.md b/Other/swap_out-loop.md index 91e457f..9068f65 100644 --- a/Other/swap_out-loop.md +++ b/Other/swap_out-loop.md @@ -23,11 +23,16 @@ Rather than simply finding channels with high local balances, `swap_out-loop.py` - **Direct 2-Hop Route Fallback**: If multi-hop routes fail due to intermediate bottlenecks or dead gossip nodes, the tool automatically checks if the candidate peer maintains a direct channel to the Loop node (`021c97a9...`), constructing the route via `lncli buildroute` and probing it with `lncli sendtoroute` to accurately capture the exact verified route fee. - **Concurrent Worker Probing (`--workers `)**: - Probes candidates sequentially by default for zero downstream HTLC collision, or in parallel via `--workers 2` using `ThreadPoolExecutor` with thread-safe output formatting, cutting scanning time in half. -- **Interactive Terminal UI (Arrow-Key Navigation)**: +- **Multi-Channel Outgoing Pooling (`--max-channels `)**: + - Pools surplus local liquidity across multiple channels when target amount exceeds individual channel capacity (e.g., pooling 3M + 3M + 2M to execute a single 8M swap). + - Passes comma-separated channel IDs directly to `litloop out --channel ,...` (native Loop daemon feature). + - Bypasses single-channel bottlenecks, avoids stranded prepayments if a single peer experiences churn or mass channel closures, and optimizes routing path diversity. +- **Interactive Terminal UI with Multi-Select**: - Native Python implementation (`termios` and `tty`) with zero Node/npm/npx dependencies. - Navigate candidates with `↑` / `↓` (or `k` / `j`) arrow keys, showing real-time highlighted selection and cost breakdown. - - Press `[Enter]` to select and confirm, or `[q]` / `[Esc]` to abort. - - Automatically falls back to a clean PrettyTable and numbered prompt in non-interactive/piped environments. + - Press `[Space]` to toggle individual channels (`[✓]`), `[a]` to toggle all up to `--max-channels`, and `[Enter]` to confirm the batch. + - Displays dynamic combined swap size and aggregate PPM in real time. + - Automatically falls back to greedy batching and clean PrettyTable in non-interactive/piped environments. - **Economical Sweep Timing**: - Enforces a minimum confirmation target of 6 blocks (default: 9) to prevent overpaying for fast on-chain sweeps. - Omits `--fast` so Loop's swap server batches the on-chain HTLC publication, reducing chain fees. @@ -120,7 +125,13 @@ python3 Other/swap_out-loop.py --capacity 5000000 --fee-limit 50 --min-ratio 70 # Sweeping to an external cold-storage address with custom 12-block confirmation target python3 Other/swap_out-loop.py --amt 3000000 --conf-target 12 --dest-addr bc1q... -# Non-interactive / headless automation (automatically selects top-ranked candidate) +# Multi-channel batching (e.g., pooling multiple channels up to 8M total) +python3 Other/swap_out-loop.py --amt 8000000 --max-channels 3 + +# Target specific outgoing channels directly +python3 Other/swap_out-loop.py --channel 1055691691402854401,896468114071224320 --amt 8000000 + +# Non-interactive / headless automation (automatically selects top-ranked candidate or greedy batch) python3 Other/swap_out-loop.py --amt 2000000 --auto-approve # View historical loop-out operations from Loop's SOT database @@ -144,6 +155,8 @@ python3 Other/swap_out-loop.py --history --csv | `--skip-prepay-probe` | `flag` | `False` | Skip active prepay probing and rely on queryroutes theoretical fees (not recommended). | | `--max-routing-fee`| `int` | Leeway buffer | Upper limit on off-chain routing fees in satoshis. Set to 0 to omit fee limit (uses Loop daemon default). | | `--fee-leeway-pct` | `float`| Config / `100%` | Percentage leeway added on top of probed routing fee for max off-chain fee budget (e.g. 100 = 2x headroom). | +| `--max-channels` | `int` | Config / `3` | Maximum number of outgoing channels to batch in a multi-channel Loop Out. | +| `--channel`, `--channels` | `str` | `None` | Comma-separated list of short channel IDs to target directly. | | `--dest-addr` | `str` | LND wallet | Custom destination address for swept on-chain funds. | | `--dry-run` | `flag` | `False` | Simulates candidate selection, live quotes, and route probes without executing. | | `--auto-approve` | `flag` | `False` | Automatically executes the top-ranked candidate without interactive prompt. | diff --git a/Other/swap_out-loop.py b/Other/swap_out-loop.py index 4472145..2bcaddf 100644 --- a/Other/swap_out-loop.py +++ b/Other/swap_out-loop.py @@ -30,7 +30,7 @@ import sqlite3 import csv import logging -from typing import List, Dict, Any, Tuple, Optional +from typing import List, Dict, Any, Tuple, Optional, Union import requests from prettytable import PrettyTable @@ -248,12 +248,13 @@ def filter_and_size_candidates( continue # Dynamic or fixed sizing + reserve = max(100_000, int(capacity * 0.05)) + drainable_surplus = max(0, local_balance - reserve) + if target_amt is not None and target_amt > 0: - proposed_amt = target_amt - # Verify channel has sufficient liquidity leaving reasonable reserve (5% or 100k) - reserve = max(100_000, int(capacity * 0.05)) - if local_balance - proposed_amt < reserve: + if drainable_surplus < MIN_LOOP_OUT_SATS: continue + proposed_amt = min(target_amt, drainable_surplus) else: # Rebalance channel down to target equilibrium ratio (e.g. 50%) target_local_balance = int(capacity * (target_local_ratio / 100.0)) @@ -274,6 +275,7 @@ def filter_and_size_candidates( "local_ratio": local_ratio, "local_fee_rate": local_fee_rate, "proposed_amt": proposed_amt, + "drainable_surplus": drainable_surplus, } candidates.append(candidate) @@ -822,20 +824,45 @@ def print_candidates_table(candidates: List[Dict[str, Any]]) -> None: print(table) -def interactive_menu_select(candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: +def interactive_menu_select( + candidates: List[Dict[str, Any]], + target_amt: Optional[int] = None, + max_channels: int = 3, +) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]: """ - Renders an interactive CLI terminal menu with arrow-key navigation. - Falls back to text input if terminal is non-interactive. + Renders an interactive CLI terminal menu with arrow-key navigation, + Spacebar multi-select toggling, and greedy batching fallback. """ if not candidates: return None if not sys.stdin.isatty(): + # If target_amt is specified and exceeds first candidate's drainable surplus, + # greedily pool top candidates to meet target_amt up to max_channels. + first_cand_capacity = candidates[0].get("drainable_surplus", candidates[0]["proposed_amt"]) + if target_amt is not None and target_amt > first_cand_capacity: + batch = [] + accumulated = 0 + for c in candidates: + batch.append(c) + accumulated += c.get("drainable_surplus", c.get("proposed_amt", 0)) + if accumulated >= target_amt or len(batch) >= max_channels: + break + if len(batch) > 1: + return batch + # Non-interactive fallback print("\nAvailable Candidates:") print_candidates_table(candidates) try: - choice = input(f"\nSelect candidate [1-{len(candidates)}] or [q] to cancel: ").strip().lower() + prompt_msg = f"\nSelect candidate [1-{len(candidates)}] (comma-separated for multi) or [q] to cancel: " + choice = input(prompt_msg).strip().lower() + if not choice or choice == "q": + return None + if "," in choice: + indices = [int(x.strip()) - 1 for x in choice.split(",") if x.strip().isdigit()] + valid = [candidates[i] for i in indices if 0 <= i < len(candidates)] + return valid if valid else None if choice.isdigit() and 1 <= int(choice) <= len(candidates): return candidates[int(choice) - 1] except (EOFError, KeyboardInterrupt): @@ -844,12 +871,16 @@ def interactive_menu_select(candidates: List[Dict[str, Any]]) -> Optional[Dict[s current_idx = 0 total = len(candidates) + selected_indices = set() while True: # Clear screen segment and render - print("\033[2J\033[H", end="") # Clear screen and move to top-left + print("", end="") # Clear screen and move to top-left print_color("=== Lightning Loop Out - Economic Channel Selection ===", Colors.HEADER, bold=True) - print_color("Use [↑/k] and [↓/j] to navigate, [Enter] to select, [q] to cancel.\n", Colors.OKCYAN) + print_color( + "Use [↑/k] & [↓/j] to navigate, [Space] to toggle multi-channel, [a] toggle all, [Enter] to select, [q] to cancel.\n", + Colors.OKCYAN, + ) table = PrettyTable() table.field_names = [ @@ -870,11 +901,15 @@ def interactive_menu_select(candidates: List[Dict[str, Any]]) -> Optional[Dict[s table.align["Sel"] = "c" for idx, c in enumerate(candidates): - is_selected = idx == current_idx - sel_mark = "▶" if is_selected else " " + is_active_cursor = idx == current_idx + is_checked = idx in selected_indices + + cursor_mark = "▶" if is_active_cursor else " " + check_mark = "[✓]" if is_checked else "[ ]" + sel_display = f"{cursor_mark} {check_mark}" row = [ - sel_mark, + sel_display, c["alias"][:20], c["chan_id"], f"{c['local_ratio']:.1f}%", @@ -887,32 +922,64 @@ def interactive_menu_select(candidates: List[Dict[str, Any]]) -> Optional[Dict[s f"{c['effective_ppm']:,}", ] - if is_selected: + if is_active_cursor: # Highlight active row in color row = [f"{Colors.OKGREEN}{Colors.BOLD}{val}{Colors.ENDC}" for val in row] + elif is_checked: + row = [f"{Colors.OKCYAN}{val}{Colors.ENDC}" for val in row] table.add_row(row) print(table) print() - selected_cand = candidates[current_idx] - print_color( - f"Active: {selected_cand['alias']} | Swap: {selected_cand['proposed_amt']:,} sats | Total Cost: {selected_cand['total_cost']:,} sats ({selected_cand['effective_ppm']} ppm)", - Colors.OKBLUE, - bold=True, - ) + + if selected_indices: + sel_list = [candidates[i] for i in sorted(selected_indices)] + comb_amt = sum(c["proposed_amt"] for c in sel_list) + comb_cost = sum(c["total_cost"] for c in sel_list) + comb_ppm = round((comb_cost / comb_amt) * 1_000_000) if comb_amt > 0 else 0 + print_color( + f"Multi-Select Active ({len(selected_indices)} chans): Combined Size {comb_amt:,} sats | Est Total Cost: {comb_cost:,} sats ({comb_ppm} ppm)", + Colors.OKGREEN, + bold=True, + ) + print_color("Press [Enter] to execute multi-channel swap with selected channels.", Colors.OKBLUE) + else: + selected_cand = candidates[current_idx] + print_color( + f"Active: {selected_cand['alias']} | Swap: {selected_cand['proposed_amt']:,} sats | Total Cost: {selected_cand['total_cost']:,} sats ({selected_cand['effective_ppm']} ppm)", + Colors.OKBLUE, + bold=True, + ) key = read_single_keypress() - if key in ("\x1b[A", "k", "K"): # Up + if key in ("", "k", "K"): # Up current_idx = (current_idx - 1) % total - elif key in ("\x1b[B", "j", "J"): # Down + elif key in ("", "j", "J"): # Down current_idx = (current_idx + 1) % total - elif key in ("\r", "\n", " "): # Enter or Space to select + elif key == " ": # Space toggles selection + if current_idx in selected_indices: + selected_indices.remove(current_idx) + else: + if len(selected_indices) < max_channels: + selected_indices.add(current_idx) + else: + print_color(f"Max channels ({max_channels}) reached!", Colors.WARNING) + time.sleep(0.3) + elif key in ("a", "A"): # Toggle all up to max_channels + if len(selected_indices) == min(total, max_channels): + selected_indices.clear() + else: + selected_indices = set(range(min(total, max_channels))) + elif key in ("\r", "\n"): # Enter to confirm + if selected_indices: + return [candidates[i] for i in sorted(selected_indices)] return candidates[current_idx] elif key in ("q", "Q", "\x1b"): # Quit or Escape print_color("\nLoop Out selection cancelled.", Colors.WARNING) return None + def calculate_max_routing_fee_budget( probed_routing_fee: int, config: Any, @@ -950,7 +1017,7 @@ def calculate_max_routing_fee_budget( def execute_loop_out( config: Any, - channel_id: str, + channel_id: Union[str, List[str]], amt: int, conf_target: int = 9, max_routing_fee: int = 0, @@ -958,13 +1025,23 @@ def execute_loop_out( alias: str = "", dry_run: bool = False, ) -> Dict[str, Any]: - """Initiates the Loop Out swap using litloop / loop, tagging with label.""" - label = f"Loop-Out: {alias} ({channel_id})" if alias else f"Loop-Out: {channel_id}" + """ + Executes litloop out / loop out with specified channel(s), amount, conf_target, and fees. + channel_id can be a single short channel ID string or a list of channel IDs. + """ + if isinstance(channel_id, list): + chan_arg = ",".join(str(c) for c in channel_id) + chan_label = f"{len(channel_id)} chans" + else: + chan_arg = str(channel_id) + chan_label = chan_arg + + label = f"Loop-Out: {alias} ({chan_label})" if alias else f"Loop-Out: {chan_label}" if dry_run: fake_swap_id = "dry-run-swap-" + binascii.hexlify(os.urandom(16)).decode() fee_arg = f" --max_swap_routing_fee {max_routing_fee}" if max_routing_fee > 0 else "" print_color( - f' Command: litloop out --amt {amt} --channel {channel_id} --conf_target {conf_target} --label "{label}" --force{fee_arg}', + f' Command: litloop out --amt {amt} --channel {chan_arg} --conf_target {conf_target} --label "{label}" --force{fee_arg}', Colors.WARNING, ) return {"success": True, "swap_id": fake_swap_id, "dry_run": True} @@ -975,7 +1052,7 @@ def execute_loop_out( "--amt", str(amt), "--channel", - str(channel_id), + chan_arg, "--conf_target", str(conf_target), "--label", @@ -1147,6 +1224,19 @@ def parse_arguments() -> argparse.Namespace: default=None, help="Custom on-chain Bitcoin address for swept funds (defaults to LND internal wallet).", ) + parser.add_argument( + "--max-channels", + type=int, + default=None, + help="Maximum number of outgoing channels to batch in a multi-channel Loop Out (default from config or 3).", + ) + parser.add_argument( + "--channel", + "--channels", + type=str, + default=None, + help="Optional comma-separated list of short channel IDs to target directly.", + ) parser.add_argument( "-w", "--workers", @@ -1319,6 +1409,11 @@ def main(): blacklist=blacklist, ) + target_channel_ids = [] + if getattr(args, "channel", None): + target_channel_ids = [c.strip() for c in args.channel.split(",") if c.strip()] + candidates = [c for c in candidates if c["chan_id"] in target_channel_ids] + if not candidates: print_color("No suitable candidate channels found matching criteria.", Colors.WARNING) sys.exit(0) @@ -1379,31 +1474,73 @@ def main(): evaluated_candidates.sort(key=lambda x: x["effective_ppm"]) # Selection + max_channels = args.max_channels or config.getint("loop", "max_channels", fallback=3) selected = None if args.auto_approve: print("\nEvaluated Candidates:") print_candidates_table(evaluated_candidates) - selected = evaluated_candidates[0] - print_color(f"\nAuto-approved top candidate: {selected['alias']} ({selected['chan_id']})", Colors.OKGREEN, bold=True) + top_cap = evaluated_candidates[0].get("drainable_surplus", evaluated_candidates[0]["proposed_amt"]) + if args.amt and args.amt > top_cap: + batch = [] + acc = 0 + for c in evaluated_candidates: + batch.append(c) + acc += c.get("drainable_surplus", c.get("proposed_amt", 0)) + if acc >= args.amt or len(batch) >= max_channels: + break + selected = batch + else: + selected = evaluated_candidates[0] + + if isinstance(selected, list): + aliases = ", ".join(c["alias"] for c in selected) + print_color(f"\nAuto-approved batch of {len(selected)} candidates: {aliases}", Colors.OKGREEN, bold=True) + else: + print_color(f"\nAuto-approved top candidate: {selected['alias']} ({selected['chan_id']})", Colors.OKGREEN, bold=True) else: - selected = interactive_menu_select(evaluated_candidates) + selected = interactive_menu_select(evaluated_candidates, target_amt=args.amt, max_channels=max_channels) if not selected: print_color("Operation cancelled. No swap initiated.", Colors.WARNING) sys.exit(0) + selected_channels = [selected] if isinstance(selected, dict) else selected + + if len(selected_channels) == 1: + sel = selected_channels[0] + chan_ids = sel["chan_id"] + alias_str = sel["alias"] + total_swap_amt = sel["proposed_amt"] + probed_routing_fee = sel["routing_fee"] + total_cost = sel["total_cost"] + else: + chan_ids = [c["chan_id"] for c in selected_channels] + alias_str = f"{selected_channels[0]['alias']} + {len(selected_channels) - 1} more ({len(selected_channels)} chans)" + if args.amt and args.amt > 0: + total_swap_amt = min(args.amt, sum(c["proposed_amt"] for c in selected_channels)) + else: + total_swap_amt = sum(c["proposed_amt"] for c in selected_channels) + total_swap_amt = min(total_swap_amt, MAX_LOOP_OUT_SATS) + probed_routing_fee = sum(c["routing_fee"] for c in selected_channels) + total_cost = sum(c["total_cost"] for c in selected_channels) + + print_color(f"\nMulti-Channel Loop Out Batch Selected ({len(selected_channels)} channels):", Colors.OKGREEN, bold=True) + for idx, sc in enumerate(selected_channels, 1): + print(f" [{idx}] {sc['alias']} ({sc['chan_id']}): {sc['proposed_amt']:,} sats (Route: {sc['routing_fee']:,} sat, Net PPM: {sc['effective_ppm']})") + print(f" Combined Swap Size: {total_swap_amt:,} sats") + # Max routing fee budget with configurable leeway max_rf = calculate_max_routing_fee_budget( - probed_routing_fee=selected["routing_fee"], + probed_routing_fee=probed_routing_fee, config=config, explicit_max_routing_fee=args.max_routing_fee, explicit_leeway_pct=args.fee_leeway_pct, ) if max_rf > 0: - buffer_sats = max_rf - selected["routing_fee"] + buffer_sats = max_rf - probed_routing_fee print_color( - f"Routing Fee Budget: {max_rf:,} sats (Probed: {selected['routing_fee']:,} sats + {buffer_sats:,} sat leeway)", + f"Routing Fee Budget: {max_rf:,} sats (Probed: {probed_routing_fee:,} sats + {buffer_sats:,} sat leeway)", Colors.OKCYAN, ) else: @@ -1415,12 +1552,12 @@ def main(): # Execute res = execute_loop_out( config=config, - channel_id=selected["chan_id"], - amt=selected["proposed_amt"], + channel_id=chan_ids, + amt=total_swap_amt, conf_target=conf_target, max_routing_fee=max_rf, dest_addr=args.dest_addr, - alias=selected["alias"], + alias=alias_str, dry_run=args.dry_run, ) diff --git a/config.ini.example b/config.ini.example index 15c96fd..cc9b137 100644 --- a/config.ini.example +++ b/config.ini.example @@ -120,6 +120,8 @@ probe_timeout = 15 fee_leeway_pct = 100 # Base satoshis buffer added to fee leeway to absorb base fees and small fluctuations (default: 500) fee_leeway_base_sats = 500 +# Maximum number of outgoing channels to batch in a multi-channel Loop Out (default: 3) +max_channels = 3 # Loop daemon connection parameters (used if loop_command is 'loop' instead of 'litloop') # rpcserver = localhost:8443 # tlscertpath = ~/.lit/tls.cert diff --git a/tests/Other/test_swap_out_loop.py b/tests/Other/test_swap_out_loop.py index eb69f54..ff29d42 100644 --- a/tests/Other/test_swap_out_loop.py +++ b/tests/Other/test_swap_out_loop.py @@ -572,3 +572,147 @@ def test_calculate_max_routing_fee_budget_cli_leeway_override(): ) # 20,000 * 3.0 + 500 = 60,500 assert budget == 60_500 + + +def test_filter_and_size_candidates_multi_channel_pooling(sample_channels): + """Verify channels with drainable surplus qualify when target_amt exceeds single channel capacity.""" + blacklist = ["02blacklist1111111111111111111111111111111111111111111111111111111"] + # User requests 15,000,000 sats (no single channel in sample_channels has 15M) + candidates = swap_out_loop.filter_and_size_candidates( + channels=sample_channels, + target_amt=15_000_000, + min_capacity=3_000_000, + max_fee_rate=500, + min_local_ratio=60.0, + blacklist=blacklist, + ) + # Both Cheap (8.5M local) and Expensive (9M local) have drainable surplus >= 250k sats + assert len(candidates) == 2 + for c in candidates: + assert c["drainable_surplus"] >= 8_000_000 + assert c["proposed_amt"] <= 15_000_000 + + +def test_execute_loop_out_multi_channel_list(): + """Verify execute_loop_out accepts a list of channel IDs and formats comma-separated argument.""" + res = swap_out_loop.execute_loop_out( + config={}, + channel_id=["1055691691402854401", "896468114071224320"], + amt=8_000_000, + conf_target=9, + max_routing_fee=40_000, + alias="Sunny Sarah ☀️ + 1 more (2 chans)", + dry_run=True, + ) + assert res["success"] is True + assert "dry-run-swap-" in res["swap_id"] + + +def test_interactive_menu_select_multi_channel_non_tty(): + """Verify non-TTY interactive_menu_select returns greedy batch when target_amt exceeds single channel.""" + candidates = [ + { + "chan_id": "111", + "alias": "Node-1", + "proposed_amt": 4_000_000, + "drainable_surplus": 4_000_000, + "local_ratio": 90.0, + "server_fee": 1000, + "onchain_fee": 150, + "routing_fee": 1000, + "opportunity_cost": 0, + "total_cost": 2150, + "effective_ppm": 537, + }, + { + "chan_id": "222", + "alias": "Node-2", + "proposed_amt": 4_000_000, + "drainable_surplus": 4_000_000, + "local_ratio": 85.0, + "server_fee": 1000, + "onchain_fee": 150, + "routing_fee": 1200, + "opportunity_cost": 0, + "total_cost": 2350, + "effective_ppm": 587, + }, + { + "chan_id": "333", + "alias": "Node-3", + "proposed_amt": 4_000_000, + "drainable_surplus": 4_000_000, + "local_ratio": 80.0, + "server_fee": 1000, + "onchain_fee": 150, + "routing_fee": 2000, + "opportunity_cost": 0, + "total_cost": 3150, + "effective_ppm": 787, + }, + ] + + # When target_amt is 7,000,000, first channel alone (4M) is not enough. + # Non-TTY greedy batching should return 2 channels (111 and 222). + selected = swap_out_loop.interactive_menu_select(candidates, target_amt=7_000_000, max_channels=3) + assert selected is not None + assert isinstance(selected, list) + assert len(selected) == 2 + assert selected[0]["chan_id"] == "111" + assert selected[1]["chan_id"] == "222" + + +def test_parse_arguments_multi_channel(): + """Verify --max-channels and --channel CLI arguments parse correctly.""" + with patch("sys.argv", ["swap_out-loop.py", "--max-channels", "4", "--channel", "111,222"]): + args = swap_out_loop.parse_arguments() + assert args.max_channels == 4 + assert args.channel == "111,222" + + +def test_interactive_menu_select_multi_channel_comma_input(): + """Verify non-TTY interactive_menu_select handles comma-separated manual input.""" + candidates = [ + {"chan_id": "111", "alias": "Node-1", "proposed_amt": 2_000_000, "drainable_surplus": 2_000_000, "local_ratio": 80.0, "server_fee": 500, "onchain_fee": 150, "routing_fee": 100, "opportunity_cost": 0, "total_cost": 750, "effective_ppm": 375}, + {"chan_id": "222", "alias": "Node-2", "proposed_amt": 2_000_000, "drainable_surplus": 2_000_000, "local_ratio": 75.0, "server_fee": 500, "onchain_fee": 150, "routing_fee": 150, "opportunity_cost": 0, "total_cost": 800, "effective_ppm": 400}, + {"chan_id": "333", "alias": "Node-3", "proposed_amt": 2_000_000, "drainable_surplus": 2_000_000, "local_ratio": 70.0, "server_fee": 500, "onchain_fee": 150, "routing_fee": 200, "opportunity_cost": 0, "total_cost": 850, "effective_ppm": 425}, + ] + with patch("sys.stdin.isatty", return_value=False): + with patch("builtins.input", return_value="1,3"): + selected = swap_out_loop.interactive_menu_select(candidates, target_amt=None, max_channels=3) + assert isinstance(selected, list) + assert len(selected) == 2 + assert selected[0]["chan_id"] == "111" + assert selected[1]["chan_id"] == "333" + + +def test_execute_loop_out_multi_channel_real_command(): + """Verify execute_loop_out formats command arguments correctly for real execution.""" + with patch.object(swap_out_loop, "resolve_loop_command", return_value=["litloop"]): + with patch.object(swap_out_loop, "run_command", return_value=(True, "Swap initiated: 12345", None)) as mock_run: + res = swap_out_loop.execute_loop_out( + config={}, + channel_id=["111", "222"], + amt=6_000_000, + conf_target=9, + max_routing_fee=1500, + dest_addr="bc1qtestaddr", + alias="Multi-Peer", + dry_run=False, + ) + assert res["success"] is True + assert res["swap_id"] == "12345" + mock_run.assert_called_once() + called_cmd = mock_run.call_args[0][0] + assert "--channel" in called_cmd + chan_idx = called_cmd.index("--channel") + assert called_cmd[chan_idx + 1] == "111,222" + assert "--amt" in called_cmd + amt_idx = called_cmd.index("--amt") + assert called_cmd[amt_idx + 1] == "6000000" + assert "--max_swap_routing_fee" in called_cmd + fee_idx = called_cmd.index("--max_swap_routing_fee") + assert called_cmd[fee_idx + 1] == "1500" + assert "--addr" in called_cmd + addr_idx = called_cmd.index("--addr") + assert called_cmd[addr_idx + 1] == "bc1qtestaddr" From e184bd285f06c4f864d9fda44392ea521a930e39 Mon Sep 17 00:00:00 2001 From: Hakuna Date: Mon, 21 Sep 2026 19:36:44 +0200 Subject: [PATCH 2/6] fix(loop): fix multi-channel logging TypeError, label duplication, and menu size capping - Fix TypeError in main() when logging initiated multi-channel swap by using alias_str and chan_ids_str instead of indexing selected dict. - Fix duplicated '(X chans) (X chans)' in litloop label by removing redundant channel count suffix in main(). - Cap displayed combined swap size in interactive menu footer to target_amt when explicitly specified. - Add regression unit test test_main_multi_channel_logging_success. --- Other/swap_out-loop.py | 12 ++++-- tests/Other/test_swap_out_loop.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/Other/swap_out-loop.py b/Other/swap_out-loop.py index 2bcaddf..fc15eff 100644 --- a/Other/swap_out-loop.py +++ b/Other/swap_out-loop.py @@ -934,7 +934,10 @@ def interactive_menu_select( if selected_indices: sel_list = [candidates[i] for i in sorted(selected_indices)] - comb_amt = sum(c["proposed_amt"] for c in sel_list) + if target_amt and target_amt > 0: + comb_amt = min(target_amt, sum(c["proposed_amt"] for c in sel_list)) + else: + comb_amt = sum(c["proposed_amt"] for c in sel_list) comb_cost = sum(c["total_cost"] for c in sel_list) comb_ppm = round((comb_cost / comb_amt) * 1_000_000) if comb_amt > 0 else 0 print_color( @@ -1515,7 +1518,7 @@ def main(): total_cost = sel["total_cost"] else: chan_ids = [c["chan_id"] for c in selected_channels] - alias_str = f"{selected_channels[0]['alias']} + {len(selected_channels) - 1} more ({len(selected_channels)} chans)" + alias_str = f"{selected_channels[0]['alias']} + {len(selected_channels) - 1} more" if args.amt and args.amt > 0: total_swap_amt = min(args.amt, sum(c["proposed_amt"] for c in selected_channels)) else: @@ -1562,9 +1565,10 @@ def main(): ) if res.get("success"): + chan_ids_str = ",".join(str(c) for c in chan_ids) if isinstance(chan_ids, list) else str(chan_ids) logger.info( - f"Swap initiated for {selected['alias']} ({selected['chan_id']}): " - f"amt={selected['proposed_amt']} sats, total_cost={selected['total_cost']} sats" + f"Swap initiated for {alias_str} ({chan_ids_str}): " + f"amt={total_swap_amt} sats, total_cost={total_cost} sats" ) if not args.dry_run: diff --git a/tests/Other/test_swap_out_loop.py b/tests/Other/test_swap_out_loop.py index ff29d42..5076cc6 100644 --- a/tests/Other/test_swap_out_loop.py +++ b/tests/Other/test_swap_out_loop.py @@ -716,3 +716,66 @@ def test_execute_loop_out_multi_channel_real_command(): assert "--addr" in called_cmd addr_idx = called_cmd.index("--addr") assert called_cmd[addr_idx + 1] == "bc1qtestaddr" + + +def test_main_multi_channel_logging_success(): + """Verify main() executes and logs successfully when interactive_menu_select returns a multi-channel list.""" + mock_candidates = [ + { + "chan_id": "896468114071224320", + "alias": "block-iad-1", + "proposed_amt": 3_000_000, + "drainable_surplus": 3_000_000, + "local_ratio": 99.0, + "server_fee": 3049, + "onchain_fee": 163, + "routing_fee": 7494, + "opportunity_cost": 0, + "total_cost": 10706, + "effective_ppm": 3569, + }, + { + "chan_id": "1028289662652973056", + "alias": "allNice | torq.co", + "proposed_amt": 3_000_000, + "drainable_surplus": 3_000_000, + "local_ratio": 95.4, + "server_fee": 3049, + "onchain_fee": 163, + "routing_fee": 10497, + "opportunity_cost": 0, + "total_cost": 13709, + "effective_ppm": 4570, + }, + ] + + mock_args = MagicMock() + mock_args.history = False + mock_args.capacity = 3_000_000 + mock_args.fee_limit = 100 + mock_args.min_ratio = 60.0 + mock_args.amt = 3_000_000 + mock_args.conf_target = 9 + mock_args.max_routing_fee = None + mock_args.fee_leeway_pct = None + mock_args.dest_addr = None + mock_args.dry_run = True + mock_args.auto_approve = False + mock_args.max_channels = 3 + mock_args.channel = None + mock_args.workers = 1 + mock_args.probe_timeout = 15 + mock_args.skip_prepay_probe = False + + with patch.object(swap_out_loop, "parse_arguments", return_value=mock_args), patch.object(swap_out_loop, "load_config", return_value=(configparser.ConfigParser(), "/tmp")), patch.object(swap_out_loop, "setup_logger") as mock_setup_logger, patch.object(swap_out_loop, "fetch_channels_lndg", return_value=[{"is_active": True, "is_open": True, "capacity": 10000000, "local_balance": 8000000, "local_fee_rate": 5, "chan_id": "111", "alias": "node"}]), patch.object(swap_out_loop, "filter_and_size_candidates", return_value=mock_candidates), patch.object(swap_out_loop, "evaluate_single_candidate", side_effect=lambda c, **kwargs: c), patch.object(swap_out_loop, "interactive_menu_select", return_value=mock_candidates), patch.object(swap_out_loop, "execute_loop_out", return_value={"success": True, "swap_id": "mock-swap-id", "dry_run": True}): + + mock_logger = MagicMock() + mock_setup_logger.return_value = mock_logger + + # Calling main() should NOT raise TypeError: list indices must be integers or slices, not str + swap_out_loop.main() + + mock_logger.info.assert_called_once() + log_call_msg = mock_logger.info.call_args[0][0] + assert "block-iad-1 + 1 more" in log_call_msg + assert "896468114071224320,1028289662652973056" in log_call_msg From 11a79b8082d8fe69cbaba2e8acaf80a89e5a54fa Mon Sep 17 00:00:00 2001 From: Hakuna Date: Mon, 21 Sep 2026 19:40:22 +0200 Subject: [PATCH 3/6] feat(loop): fix swap amount and display cost range for multi-channel outbound candidates - Keep the defined loop out sum fixed to target_amt (--amt) when selecting multiple outbound channels, activating channel combinations for MPP and route diversity rather than multiplying the swap volume. - Display cost as an effective PPM and satoshi range (from cheapest candidate to most expensive candidate) in both the interactive UI footer and batch summary. - Bound the routing fee budget to the maximum probed routing fee among selected candidates plus leeway (max(fee) + leeway) rather than summing all fees. - Update documentation in Other/swap_out-loop.md and add test_multi_channel_fixed_amount_and_max_fee_budget test. --- Other/swap_out-loop.md | 8 ++-- Other/swap_out-loop.py | 41 ++++++++++++------ tests/Other/test_swap_out_loop.py | 69 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/Other/swap_out-loop.md b/Other/swap_out-loop.md index 9068f65..ec5e29f 100644 --- a/Other/swap_out-loop.md +++ b/Other/swap_out-loop.md @@ -23,15 +23,17 @@ Rather than simply finding channels with high local balances, `swap_out-loop.py` - **Direct 2-Hop Route Fallback**: If multi-hop routes fail due to intermediate bottlenecks or dead gossip nodes, the tool automatically checks if the candidate peer maintains a direct channel to the Loop node (`021c97a9...`), constructing the route via `lncli buildroute` and probing it with `lncli sendtoroute` to accurately capture the exact verified route fee. - **Concurrent Worker Probing (`--workers `)**: - Probes candidates sequentially by default for zero downstream HTLC collision, or in parallel via `--workers 2` using `ThreadPoolExecutor` with thread-safe output formatting, cutting scanning time in half. -- **Multi-Channel Outgoing Pooling (`--max-channels `)**: - - Pools surplus local liquidity across multiple channels when target amount exceeds individual channel capacity (e.g., pooling 3M + 3M + 2M to execute a single 8M swap). +- **Multi-Channel Outbound Candidates & Route Diversity (`--channel ,...`, `--max-channels `)**: + - Fixed Net Swap Size: The defined swap amount (`--amt`, e.g. 3,000,000 sats) remains fixed as the total swap volume. Activating multiple channels provides LND with multiple outbound route options and enables Multi-Path Payments (MPP) across chosen peers. + - Cost & PPM Range: Dynamically calculates and displays the effective cost range (from the cheapest candidate's PPM up to the most expensive candidate's PPM, e.g. `3,569 – 4,570 ppm`), keeping fee expectations realistic regardless of which path LND selects. + - Bounded Routing Fee Budget: Calculates the off-chain fee ceiling from the most expensive selected outbound candidate (`max(probed_routing_fee) + leeway`), ensuring LND has sufficient fee room without overpaying. - Passes comma-separated channel IDs directly to `litloop out --channel ,...` (native Loop daemon feature). - Bypasses single-channel bottlenecks, avoids stranded prepayments if a single peer experiences churn or mass channel closures, and optimizes routing path diversity. - **Interactive Terminal UI with Multi-Select**: - Native Python implementation (`termios` and `tty`) with zero Node/npm/npx dependencies. - Navigate candidates with `↑` / `↓` (or `k` / `j`) arrow keys, showing real-time highlighted selection and cost breakdown. - Press `[Space]` to toggle individual channels (`[✓]`), `[a]` to toggle all up to `--max-channels`, and `[Enter]` to confirm the batch. - - Displays dynamic combined swap size and aggregate PPM in real time. + - Displays live fixed swap size, PPM range (`min – max ppm`), and estimated cost range in real time. - Automatically falls back to greedy batching and clean PrettyTable in non-interactive/piped environments. - **Economical Sweep Timing**: - Enforces a minimum confirmation target of 6 blocks (default: 9) to prevent overpaying for fast on-chain sweeps. diff --git a/Other/swap_out-loop.py b/Other/swap_out-loop.py index fc15eff..0c7c102 100644 --- a/Other/swap_out-loop.py +++ b/Other/swap_out-loop.py @@ -935,13 +935,19 @@ def interactive_menu_select( if selected_indices: sel_list = [candidates[i] for i in sorted(selected_indices)] if target_amt and target_amt > 0: - comb_amt = min(target_amt, sum(c["proposed_amt"] for c in sel_list)) + swap_size = target_amt else: - comb_amt = sum(c["proposed_amt"] for c in sel_list) - comb_cost = sum(c["total_cost"] for c in sel_list) - comb_ppm = round((comb_cost / comb_amt) * 1_000_000) if comb_amt > 0 else 0 + swap_size = min(c["proposed_amt"] for c in sel_list) + + ppms = [c["effective_ppm"] for c in sel_list] + costs = [c["total_cost"] for c in sel_list] + min_ppm, max_ppm = min(ppms), max(ppms) + min_cost, max_cost = min(costs), max(costs) + ppm_str = f"{min_ppm:,} ppm" if min_ppm == max_ppm else f"{min_ppm:,} – {max_ppm:,} ppm" + cost_str = f"{min_cost:,} sats" if min_cost == max_cost else f"{min_cost:,} – {max_cost:,} sats" + print_color( - f"Multi-Select Active ({len(selected_indices)} chans): Combined Size {comb_amt:,} sats | Est Total Cost: {comb_cost:,} sats ({comb_ppm} ppm)", + f"Multi-Select Active ({len(selected_indices)} chans): Fixed Swap Size {swap_size:,} sats | Net PPM: {ppm_str} (Est Cost: {cost_str})", Colors.OKGREEN, bold=True, ) @@ -1520,17 +1526,26 @@ def main(): chan_ids = [c["chan_id"] for c in selected_channels] alias_str = f"{selected_channels[0]['alias']} + {len(selected_channels) - 1} more" if args.amt and args.amt > 0: - total_swap_amt = min(args.amt, sum(c["proposed_amt"] for c in selected_channels)) + total_swap_amt = args.amt else: - total_swap_amt = sum(c["proposed_amt"] for c in selected_channels) + total_swap_amt = min(c["proposed_amt"] for c in selected_channels) total_swap_amt = min(total_swap_amt, MAX_LOOP_OUT_SATS) - probed_routing_fee = sum(c["routing_fee"] for c in selected_channels) - total_cost = sum(c["total_cost"] for c in selected_channels) - print_color(f"\nMulti-Channel Loop Out Batch Selected ({len(selected_channels)} channels):", Colors.OKGREEN, bold=True) + probed_routing_fee = max(c["routing_fee"] for c in selected_channels) + total_cost = max(c["total_cost"] for c in selected_channels) + + ppms = [c["effective_ppm"] for c in selected_channels] + costs = [c["total_cost"] for c in selected_channels] + min_ppm, max_ppm = min(ppms), max(ppms) + min_cost, max_cost = min(costs), max(costs) + ppm_str = f"{min_ppm:,} ppm" if min_ppm == max_ppm else f"{min_ppm:,} – {max_ppm:,} ppm" + cost_str = f"{min_cost:,} sats" if min_cost == max_cost else f"{min_cost:,} – {max_cost:,} sats" + + print_color(f"\nMulti-Channel Loop Out Outbound Set ({len(selected_channels)} channels):", Colors.OKGREEN, bold=True) for idx, sc in enumerate(selected_channels, 1): - print(f" [{idx}] {sc['alias']} ({sc['chan_id']}): {sc['proposed_amt']:,} sats (Route: {sc['routing_fee']:,} sat, Net PPM: {sc['effective_ppm']})") - print(f" Combined Swap Size: {total_swap_amt:,} sats") + print(f" [{idx}] {sc['alias']} ({sc['chan_id']}): Probed Route Fee: {sc['routing_fee']:,} sat, Net PPM: {sc['effective_ppm']}") + print(f" Fixed Swap Size: {total_swap_amt:,} sats") + print(f" Effective PPM Range: {ppm_str} (Est Cost: {cost_str})") # Max routing fee budget with configurable leeway max_rf = calculate_max_routing_fee_budget( @@ -1543,7 +1558,7 @@ def main(): if max_rf > 0: buffer_sats = max_rf - probed_routing_fee print_color( - f"Routing Fee Budget: {max_rf:,} sats (Probed: {probed_routing_fee:,} sats + {buffer_sats:,} sat leeway)", + f"Routing Fee Budget: {max_rf:,} sats (Max Probed: {probed_routing_fee:,} sats + {buffer_sats:,} sat leeway)", Colors.OKCYAN, ) else: diff --git a/tests/Other/test_swap_out_loop.py b/tests/Other/test_swap_out_loop.py index 5076cc6..1f2436a 100644 --- a/tests/Other/test_swap_out_loop.py +++ b/tests/Other/test_swap_out_loop.py @@ -779,3 +779,72 @@ def test_main_multi_channel_logging_success(): log_call_msg = mock_logger.info.call_args[0][0] assert "block-iad-1 + 1 more" in log_call_msg assert "896468114071224320,1028289662652973056" in log_call_msg + + +def test_multi_channel_fixed_amount_and_max_fee_budget(): + """Verify that multi-channel execution keeps target_amt fixed and bases fee budget on max route fee.""" + mock_candidates = [ + { + "chan_id": "896468114071224320", + "alias": "block-iad-1", + "proposed_amt": 3_000_000, + "drainable_surplus": 3_000_000, + "local_ratio": 99.0, + "server_fee": 3049, + "onchain_fee": 163, + "routing_fee": 7494, + "opportunity_cost": 0, + "total_cost": 10706, + "effective_ppm": 3569, + }, + { + "chan_id": "1028289662652973056", + "alias": "allNice | torq.co", + "proposed_amt": 3_000_000, + "drainable_surplus": 3_000_000, + "local_ratio": 95.4, + "server_fee": 3049, + "onchain_fee": 163, + "routing_fee": 10497, + "opportunity_cost": 0, + "total_cost": 13709, + "effective_ppm": 4570, + }, + ] + + mock_args = MagicMock() + mock_args.history = False + mock_args.capacity = 3_000_000 + mock_args.fee_limit = 100 + mock_args.min_ratio = 60.0 + mock_args.amt = 3_000_000 # User specified 3,000,000 sats + mock_args.conf_target = 9 + mock_args.max_routing_fee = None + mock_args.fee_leeway_pct = 100.0 # +100% leeway + mock_args.dest_addr = None + mock_args.dry_run = True + mock_args.auto_approve = False + mock_args.max_channels = 3 + mock_args.channel = None + mock_args.workers = 1 + mock_args.probe_timeout = 15 + mock_args.skip_prepay_probe = False + + config = configparser.ConfigParser() + config.add_section("loop") + config.set("loop", "fee_leeway_pct", "100.0") + config.set("loop", "fee_leeway_base_sats", "500") + + with patch.object(swap_out_loop, "parse_arguments", return_value=mock_args), patch.object(swap_out_loop, "load_config", return_value=(config, "/tmp")), patch.object(swap_out_loop, "setup_logger") as mock_setup_logger, patch.object(swap_out_loop, "fetch_channels_lndg", return_value=[{"is_active": True, "is_open": True, "capacity": 10000000, "local_balance": 8000000, "local_fee_rate": 5, "chan_id": "111", "alias": "node"}]), patch.object(swap_out_loop, "filter_and_size_candidates", return_value=mock_candidates), patch.object(swap_out_loop, "evaluate_single_candidate", side_effect=lambda c, **kwargs: c), patch.object(swap_out_loop, "interactive_menu_select", return_value=mock_candidates), patch.object(swap_out_loop, "execute_loop_out", return_value={"success": True, "swap_id": "mock-swap-id", "dry_run": True}) as mock_exec: + + mock_logger = MagicMock() + mock_setup_logger.return_value = mock_logger + + swap_out_loop.main() + + # execute_loop_out must be called with amt=3,000,000 (NOT 6,000,000) + assert mock_exec.call_args[1]["amt"] == 3_000_000 + # max_routing_fee must be based on max(7494, 10497) = 10497 -> 10497 * 2.0 + 500 = 21494 + assert mock_exec.call_args[1]["max_routing_fee"] == 21_494 + # channel_id list contains both channels + assert mock_exec.call_args[1]["channel_id"] == ["896468114071224320", "1028289662652973056"] From e337e513a59bab495b529c5c0184f8bed521da06 Mon Sep 17 00:00:00 2001 From: Hakuna Date: Mon, 21 Sep 2026 19:43:49 +0200 Subject: [PATCH 4/6] docs(readme): highlight multi-channel route selection and MPP diversity for Loop Out --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a1db599..96c9242 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Manage on-chain UTXOs, submarine swaps, and dynamic channel fee pricing. | [`Other/rebalance_guard.py`](Other/rebalance_guard.py) | `cronjob` / `CLI tool` | **Standalone Rebalance Guard**: Audits all open LNDg channels across both native Auto-Fees and `fee_adjuster.py`. Protects channels with active inbound discounts by setting `ar_out_target = 100%`, and automatically restores baseline targets using **Dynamic Hysteresis** once liquidity recovers. | | [`Other/boltz_swap-out.py`](Other/boltz_swap-out.py) | `CLI tool` | Automates submarine swap-outs through the Boltz exchange (Lightning to Liquid L-BTC). | | [`Other/swap_wallet.py`](Other/swap_wallet.py) | `CLI tool` | Batches automated payouts or drain payments over Lightning to a designated Lightning Address. | -| [`Other/swap_out-loop.py`](Other/swap_out-loop.py) | `CLI tool` | Economically optimized Loop Out liquidity rebalancing via Lightning Labs Loop (`litloop` / `loopd`) with route probing, economic scoring, arrow-key interactive menu, and SQLite accounting. See [documentation](Other/swap_out-loop.md). | +| [`Other/swap_out-loop.py`](Other/swap_out-loop.py) | `CLI tool` | Economically optimized Loop Out liquidity rebalancing via Lightning Labs Loop (`litloop` / `loopd`) with live route probing, multi-channel outbound route selection (MPP diversity), economic scoring, interactive UI, and SQLite accounting. See [documentation](Other/swap_out-loop.md). | | [`Other/lnd_utxo_consolidator.py`](Other/lnd_utxo_consolidator.py) | `CLI tool` | Safely consolidates fragmented on-chain LND UTXOs during low-mempool fee environments. | --- From 4be8f72739d8b8154ac5b35ee9772d0e21c83fa9 Mon Sep 17 00:00:00 2001 From: Hakuna Date: Mon, 21 Sep 2026 21:39:37 +0200 Subject: [PATCH 5/6] fix(loop): include all peer aliases in label, prevent duplication, and enhance SOT sweep status --- Other/swap_out-loop.py | 120 +++++++++++++++++++++++------- tests/Other/test_swap_out_loop.py | 116 ++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 29 deletions(-) diff --git a/Other/swap_out-loop.py b/Other/swap_out-loop.py index 0c7c102..1699fbd 100644 --- a/Other/swap_out-loop.py +++ b/Other/swap_out-loop.py @@ -637,40 +637,88 @@ def fetch_loop_history_from_db(db_path: str, limit: int = 50) -> List[Dict[str, conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) conn.row_factory = sqlite3.Row cur = conn.cursor() - query = """ - SELECT - hex(s.swap_hash) AS swap_id, - s.initiation_time, - s.amount_requested AS amount, - s.label, - lo.outgoing_chan_set, - lo.dest_address, - su.update_state, - COALESCE(su.server_cost, 0) AS server_cost, - COALESCE(su.onchain_cost, 0) AS onchain_cost, - COALESCE(su.offchain_cost, 0) AS offchain_cost - FROM loopout_swaps lo - JOIN swaps s ON lo.swap_hash = s.swap_hash - LEFT JOIN ( - SELECT swap_hash, update_state, server_cost, onchain_cost, offchain_cost, - ROW_NUMBER() OVER (PARTITION BY swap_hash ORDER BY update_timestamp DESC) as rn - FROM swap_updates - ) su ON lo.swap_hash = su.swap_hash AND su.rn = 1 - ORDER BY s.initiation_time DESC - LIMIT ?; - """ + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sweeps';") + has_sweeps = cur.fetchone() is not None + + if has_sweeps: + query = """ + SELECT + hex(s.swap_hash) AS swap_id, + s.initiation_time, + s.amount_requested AS amount, + s.label, + lo.outgoing_chan_set, + lo.dest_address, + su.update_state, + COALESCE(su.server_cost, 0) AS server_cost, + COALESCE(su.onchain_cost, 0) AS onchain_cost, + COALESCE(su.offchain_cost, 0) AS offchain_cost, + sw.completed AS sweep_completed, + sw.amt AS sweep_amt, + sb.batch_tx_id + FROM loopout_swaps lo + JOIN swaps s ON lo.swap_hash = s.swap_hash + LEFT JOIN ( + SELECT swap_hash, update_state, server_cost, onchain_cost, offchain_cost, + ROW_NUMBER() OVER (PARTITION BY swap_hash ORDER BY update_timestamp DESC) as rn + FROM swap_updates + ) su ON lo.swap_hash = su.swap_hash AND su.rn = 1 + LEFT JOIN sweeps sw ON lo.swap_hash = sw.swap_hash + LEFT JOIN sweep_batches sb ON sw.batch_id = sb.id + ORDER BY s.initiation_time DESC + LIMIT ?; + """ + else: + query = """ + SELECT + hex(s.swap_hash) AS swap_id, + s.initiation_time, + s.amount_requested AS amount, + s.label, + lo.outgoing_chan_set, + lo.dest_address, + su.update_state, + COALESCE(su.server_cost, 0) AS server_cost, + COALESCE(su.onchain_cost, 0) AS onchain_cost, + COALESCE(su.offchain_cost, 0) AS offchain_cost, + NULL AS sweep_completed, + NULL AS sweep_amt, + NULL AS batch_tx_id + FROM loopout_swaps lo + JOIN swaps s ON lo.swap_hash = s.swap_hash + LEFT JOIN ( + SELECT swap_hash, update_state, server_cost, onchain_cost, offchain_cost, + ROW_NUMBER() OVER (PARTITION BY swap_hash ORDER BY update_timestamp DESC) as rn + FROM swap_updates + ) su ON lo.swap_hash = su.swap_hash AND su.rn = 1 + ORDER BY s.initiation_time DESC + LIMIT ?; + """ cur.execute(query, (limit,)) rows = [] for r in cur.fetchall(): server_fee = int(r["server_cost"]) onchain_fee = int(r["onchain_cost"]) routing_fee = int(r["offchain_cost"]) - total_cost = server_fee + onchain_fee + routing_fee amt = int(r["amount"]) + + sweep_completed = r["sweep_completed"] + batch_tx_id = r["batch_tx_id"] + sweep_amt = int(r["sweep_amt"]) if r["sweep_amt"] is not None else 0 + + # If onchain fee is 0 in swap_updates but a sweep batch tx exists, + # estimate/calculate the pending onchain fee from the sweep output + if onchain_fee == 0 and batch_tx_id and sweep_amt > 0 and amt >= sweep_amt: + onchain_fee = amt - sweep_amt + + total_cost = server_fee + onchain_fee + routing_fee ppm = int((total_cost * 1_000_000) / amt) if amt > 0 else 0 state_code = r["update_state"] - state_str = LOOP_STATE_MAP.get(state_code, f"STATE_{state_code}" if state_code is not None else "INITIATED") + if state_code == 1 and batch_tx_id and (sweep_completed == 0 or sweep_completed is False): + state_str = "PREIMAGE_REVEALED" + else: + state_str = LOOP_STATE_MAP.get(state_code, f"STATE_{state_code}" if state_code is not None else "INITIATED") raw_time = str(r["initiation_time"]) formatted_time = raw_time.split(".")[0].replace(" +0000 UTC", "") @@ -1045,7 +1093,13 @@ def execute_loop_out( chan_arg = str(channel_id) chan_label = chan_arg - label = f"Loop-Out: {alias} ({chan_label})" if alias else f"Loop-Out: {chan_label}" + if alias: + if chan_label in alias or f"({chan_label})" in alias: + label = f"Loop-Out: {alias}" + else: + label = f"Loop-Out: {alias} ({chan_label})" + else: + label = f"Loop-Out: {chan_label}" if dry_run: fake_swap_id = "dry-run-swap-" + binascii.hexlify(os.urandom(16)).decode() fee_arg = f" --max_swap_routing_fee {max_routing_fee}" if max_routing_fee > 0 else "" @@ -1334,7 +1388,11 @@ def display_history(config: Any, limit: int = 30, csv_path: Optional[str] = None disp_label = s.get("label", "") if not disp_label: chans = s.get("outgoing_chan_set", "") - disp_label = (chans[:24] + "...") if len(chans) > 24 else chans + disp_label = (chans[:28] + "...") if len(chans) > 28 else chans + else: + if disp_label.startswith("Loop-Out: "): + disp_label = disp_label[10:] + disp_label = re.sub(r"(\(\d+\s+chans\))(?:\s+\1)+", r"\1", disp_label) status = s.get("status", "UNKNOWN") status_colored = status @@ -1349,7 +1407,7 @@ def display_history(config: Any, limit: int = 30, csv_path: Optional[str] = None [ s.get("initiation_time", ""), s.get("swap_id", "")[:12] + "...", - disp_label[:28], + disp_label[:30], f"{s.get('amount', 0):,}", f"{s.get('server_fee', 0):,}", f"{s.get('onchain_fee', 0):,}", @@ -1524,7 +1582,13 @@ def main(): total_cost = sel["total_cost"] else: chan_ids = [c["chan_id"] for c in selected_channels] - alias_str = f"{selected_channels[0]['alias']} + {len(selected_channels) - 1} more" + aliases = [c["alias"] if c.get("alias") else str(c["chan_id"]) for c in selected_channels] + if len(selected_channels) <= 3 and sum(len(a) for a in aliases) <= 60: + alias_str = ", ".join(aliases) + elif len(selected_channels) > 1: + alias_str = f"{aliases[0]} + {len(selected_channels) - 1} more" + else: + alias_str = aliases[0] if args.amt and args.amt > 0: total_swap_amt = args.amt else: diff --git a/tests/Other/test_swap_out_loop.py b/tests/Other/test_swap_out_loop.py index 1f2436a..70b2d93 100644 --- a/tests/Other/test_swap_out_loop.py +++ b/tests/Other/test_swap_out_loop.py @@ -777,7 +777,7 @@ def test_main_multi_channel_logging_success(): mock_logger.info.assert_called_once() log_call_msg = mock_logger.info.call_args[0][0] - assert "block-iad-1 + 1 more" in log_call_msg + assert "block-iad-1, allNice | torq.co" in log_call_msg assert "896468114071224320,1028289662652973056" in log_call_msg @@ -848,3 +848,117 @@ def test_multi_channel_fixed_amount_and_max_fee_budget(): assert mock_exec.call_args[1]["max_routing_fee"] == 21_494 # channel_id list contains both channels assert mock_exec.call_args[1]["channel_id"] == ["896468114071224320", "1028289662652973056"] + + +def test_execute_loop_out_label_deduplication(): + # Verify execute_loop_out does not duplicate (X chans) if already in alias + with patch.object(swap_out_loop, "resolve_loop_command", return_value=["litloop"]): + with patch.object(swap_out_loop, "run_command", return_value=(True, "Swap initiated: 999", None)) as mock_run: + # Case 1: alias already contains (2 chans) + swap_out_loop.execute_loop_out( + config={}, + channel_id=["111", "222"], + amt=3_000_000, + alias="block-iad-1 + 1 more (2 chans)", + dry_run=False, + ) + cmd1 = mock_run.call_args[0][0] + label_idx1 = cmd1.index("--label") + assert cmd1[label_idx1 + 1] == "Loop-Out: block-iad-1 + 1 more (2 chans)" + + # Case 2: alias without channel count + swap_out_loop.execute_loop_out( + config={}, + channel_id=["111", "222"], + amt=3_000_000, + alias="block-iad-1 + 1 more", + dry_run=False, + ) + cmd2 = mock_run.call_args[0][0] + label_idx2 = cmd2.index("--label") + assert cmd2[label_idx2 + 1] == "Loop-Out: block-iad-1 + 1 more (2 chans)" + + +def test_fetch_loop_history_from_db_preimage_revealed(): + # Verify fetch_loop_history_from_db maps state 1 to PREIMAGE_REVEALED when sweep is pending + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "loop_sqlite.db") + conn = sqlite3.connect(db_path) + cur = conn.cursor() + cur.execute(''' + CREATE TABLE swaps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + swap_hash BLOB, + initiation_time TIMESTAMP, + amount_requested BIGINT, + label TEXT + ); + ''') + cur.execute(''' + CREATE TABLE loopout_swaps ( + swap_hash BLOB PRIMARY KEY, + dest_address TEXT, + outgoing_chan_set TEXT + ); + ''') + cur.execute(''' + CREATE TABLE swap_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + swap_hash BLOB, + update_timestamp TIMESTAMP, + update_state INTEGER, + server_cost BIGINT, + onchain_cost BIGINT, + offchain_cost BIGINT + ); + ''') + cur.execute(''' + CREATE TABLE sweep_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + confirmed BOOLEAN NOT NULL DEFAULT FALSE, + batch_tx_id TEXT + ); + ''') + cur.execute(''' + CREATE TABLE sweeps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + swap_hash BLOB NOT NULL, + batch_id INTEGER NOT NULL, + outpoint TEXT NOT NULL, + amt BIGINT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE + ); + ''') + + hash1 = bytes.fromhex("da536e98a98a16a15030d8f5224f41aaeebec1eb07d096285aaa34fd13d5379a") + cur.execute( + "INSERT INTO swaps (swap_hash, initiation_time, amount_requested, label) VALUES (?, ?, ?, ?)", + (hash1, "2026-09-21 17:34:59", 3000000, "Loop-Out: block-iad-1 + 1 more (2 chans)") + ) + cur.execute( + "INSERT INTO loopout_swaps (swap_hash, dest_address, outgoing_chan_set) VALUES (?, ?, ?)", + (hash1, "bc1ptest", "896468114071224320,1028289662652973056") + ) + cur.execute( + "INSERT INTO swap_updates (swap_hash, update_timestamp, update_state, server_cost, onchain_cost, offchain_cost) VALUES (?, ?, ?, ?, ?, ?)", + (hash1, "2026-09-21 18:53:55", 1, 0, 0, 74) + ) + cur.execute( + "INSERT INTO sweep_batches (confirmed, batch_tx_id) VALUES (?, ?)", + (False, "393404e4c9c51490fa638cf2a25690ca70e2354bd0dc0d5fea7553f1bd6b40ff") + ) + cur.execute( + "INSERT INTO sweeps (swap_hash, batch_id, outpoint, amt, completed) VALUES (?, ?, ?, ?, ?)", + (hash1, 1, "e3e3:6", 2999887, False) + ) + conn.commit() + conn.close() + + swaps = swap_out_loop.fetch_loop_history_from_db(db_path, limit=10) + assert len(swaps) == 1 + s0 = swaps[0] + assert s0["status"] == "PREIMAGE_REVEALED" + assert s0["routing_fee"] == 74 + assert s0["onchain_fee"] == 113 + assert s0["total_cost"] == 187 + assert s0["effective_ppm"] == 62 From 514c77efe02466cc3fa13fe241f1e818313185bc Mon Sep 17 00:00:00 2001 From: Hakuna Date: Mon, 21 Sep 2026 21:40:04 +0200 Subject: [PATCH 6/6] fix(loop): add missing re import and test for display_history --- Other/swap_out-loop.py | 1 + tests/Other/test_swap_out_loop.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Other/swap_out-loop.py b/Other/swap_out-loop.py index 1699fbd..bc1ad22 100644 --- a/Other/swap_out-loop.py +++ b/Other/swap_out-loop.py @@ -18,6 +18,7 @@ """ import os +import re import sys import json import time diff --git a/tests/Other/test_swap_out_loop.py b/tests/Other/test_swap_out_loop.py index 70b2d93..3ea499b 100644 --- a/tests/Other/test_swap_out_loop.py +++ b/tests/Other/test_swap_out_loop.py @@ -962,3 +962,34 @@ def test_fetch_loop_history_from_db_preimage_revealed(): assert s0["onchain_fee"] == 113 assert s0["total_cost"] == 187 assert s0["effective_ppm"] == 62 + + +def test_display_history_deduplication_and_formatting(capsys): + # Verify display_history cleans 'Loop-Out: ' prefix and deduplicates redundant channel count + mock_swaps = [ + { + "swap_id": "da536e98a98a16a15030d8f5224f41aaeebec1eb07d096285aaa34fd13d5379a", + "initiation_time": "2026-09-21 17:34:59", + "amount": 3000000, + "label": "Loop-Out: block-iad-1 + 1 more (2 chans) (2 chans)", + "outgoing_chan_set": "896468114071224320,1028289662652973056", + "server_fee": 0, + "onchain_fee": 113, + "routing_fee": 74, + "total_cost": 187, + "effective_ppm": 62, + "status": "PREIMAGE_REVEALED", + } + ] + + config = configparser.ConfigParser() + config.add_section("loop") + config.set("loop", "loop_db_path", "/nonexistent/path/db.sqlite") + + with patch.object(swap_out_loop, "get_loop_db_path", return_value=None), patch.object(swap_out_loop, "fetch_loop_history_from_cli", return_value=mock_swaps): + swap_out_loop.display_history(config, limit=5) + + captured = capsys.readouterr().out + assert "block-iad-1 + 1 more (2 chans)" in captured + assert "(2 chans) (2 chans)" not in captured + assert "PREIMAGE_REVEALED" in captured