From d6e629bde6a65b272cda5d2ef51a3fc07e006f50 Mon Sep 17 00:00:00 2001 From: Hakuna Date: Sun, 20 Sep 2026 21:18:03 +0200 Subject: [PATCH] feat(loop): add configurable routing fee leeway to prevent payment routing failures --- Other/swap_out-loop.md | 7 +++- Other/swap_out-loop.py | 70 ++++++++++++++++++++++++++++--- config.ini.example | 4 ++ tests/Other/test_swap_out_loop.py | 57 +++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/Other/swap_out-loop.md b/Other/swap_out-loop.md index d342fa3..91e457f 100644 --- a/Other/swap_out-loop.md +++ b/Other/swap_out-loop.md @@ -91,6 +91,10 @@ target_local_ratio = 50 workers = 1 # Timeout in seconds for individual route prepay probes (default: 15) probe_timeout = 15 +# Percentage leeway added on top of probed routing fee for max off-chain fee limit (default: 100 for +100% / 2x headroom) +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 ``` --- @@ -138,7 +142,8 @@ python3 Other/swap_out-loop.py --history --csv | `-w`, `--workers` | `int` | Config / `1` | Number of concurrent worker threads for route probing (recommended: `1` or `2`). | | `--probe-timeout` | `int` | Config / `15` | Timeout in seconds for individual route prepay probes. | | `--skip-prepay-probe` | `flag` | `False` | Skip active prepay probing and rely on queryroutes theoretical fees (not recommended). | -| `--max-routing-fee`| `int` | Auto buffer | Upper limit on off-chain routing fees in satoshis. | +| `--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). | | `--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 66bdb00..4472145 100644 --- a/Other/swap_out-loop.py +++ b/Other/swap_out-loop.py @@ -913,6 +913,41 @@ def interactive_menu_select(candidates: List[Dict[str, Any]]) -> Optional[Dict[s return None +def calculate_max_routing_fee_budget( + probed_routing_fee: int, + config: Any, + explicit_max_routing_fee: Optional[int] = None, + explicit_leeway_pct: Optional[float] = None, +) -> int: + """ + Calculates the max off-chain routing fee budget to pass to loop out. + - If explicit_max_routing_fee is 0, returns 0 (unbounded / loop daemon default). + - If explicit_max_routing_fee > 0, returns that exact satoshi cap. + - Otherwise applies percentage leeway (default 100% / 2.0x) plus base satoshi buffer (default 500). + """ + if explicit_max_routing_fee is not None and explicit_max_routing_fee == 0: + return 0 + if explicit_max_routing_fee is not None and explicit_max_routing_fee > 0: + return explicit_max_routing_fee + + leeway_pct = ( + explicit_leeway_pct + if explicit_leeway_pct is not None + else ( + config.getfloat("loop", "fee_leeway_pct", fallback=100.0) + if hasattr(config, "getfloat") + else 100.0 + ) + ) + base_buffer = ( + config.getint("loop", "fee_leeway_base_sats", fallback=500) + if hasattr(config, "getint") + else 500 + ) + multiplier = 1.0 + (max(0.0, leeway_pct) / 100.0) + return int(probed_routing_fee * multiplier) + base_buffer + + def execute_loop_out( config: Any, channel_id: str, @@ -927,9 +962,9 @@ def execute_loop_out( label = f"Loop-Out: {alias} ({channel_id})" if alias else f"Loop-Out: {channel_id}" if dry_run: fake_swap_id = "dry-run-swap-" + binascii.hexlify(os.urandom(16)).decode() - print_color(f"\n[DRY RUN] Simulated litloop out execution for channel {channel_id}:", Colors.WARNING, bold=True) + 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', + f' Command: litloop out --amt {amt} --channel {channel_id} --conf_target {conf_target} --label "{label}" --force{fee_arg}', Colors.WARNING, ) return {"success": True, "swap_id": fake_swap_id, "dry_run": True} @@ -1097,8 +1132,14 @@ def parse_arguments() -> argparse.Namespace: parser.add_argument( "--max-routing-fee", type=int, - default=0, - help="Maximum off-chain swap routing fee in satoshis.", + default=None, + help="Maximum off-chain swap routing fee in satoshis. If 0, no fee limit is enforced (uses loop daemon default).", + ) + parser.add_argument( + "--fee-leeway-pct", + type=float, + default=None, + help="Percentage leeway added on top of probed routing fee for max off-chain fee limit (default from config or 100%%).", ) parser.add_argument( "--dest-addr", @@ -1351,8 +1392,25 @@ def main(): print_color("Operation cancelled. No swap initiated.", Colors.WARNING) sys.exit(0) - # Max routing fee budget - max_rf = args.max_routing_fee or int(selected["routing_fee"] * 1.5) + 50 + # Max routing fee budget with configurable leeway + max_rf = calculate_max_routing_fee_budget( + probed_routing_fee=selected["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"] + print_color( + f"Routing Fee Budget: {max_rf:,} sats (Probed: {selected['routing_fee']:,} sats + {buffer_sats:,} sat leeway)", + Colors.OKCYAN, + ) + else: + print_color( + "Routing Fee Budget: Unbounded (using Loop daemon default limit)", + Colors.OKCYAN, + ) # Execute res = execute_loop_out( diff --git a/config.ini.example b/config.ini.example index d749847..15c96fd 100644 --- a/config.ini.example +++ b/config.ini.example @@ -116,6 +116,10 @@ target_local_ratio = 50 workers = 1 # Timeout in seconds for individual route prepay probes (default: 15) probe_timeout = 15 +# Percentage leeway added on top of probed routing fee for max off-chain fee limit (default: 100 for +100% / 2x headroom) +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 # 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 96c3a17..eb69f54 100644 --- a/tests/Other/test_swap_out_loop.py +++ b/tests/Other/test_swap_out_loop.py @@ -515,3 +515,60 @@ def test_evaluate_single_candidate_success(): assert res["service_fee"] == 2000 assert res["routing_fee"] == 25 assert res["total_cost"] == 2195 + + +def test_calculate_max_routing_fee_budget_default(): + """Verify default fee leeway calculation (100% leeway / 2.0x + 500 sats buffer).""" + budget = swap_out_loop.calculate_max_routing_fee_budget( + probed_routing_fee=20_000, + config={}, + ) + # 20,000 * 2.0 + 500 = 40,500 + assert budget == 40_500 + + +def test_calculate_max_routing_fee_budget_explicit_zero_unbounded(): + """Verify that explicit 0 returns 0 (unbounded / loop daemon default).""" + budget = swap_out_loop.calculate_max_routing_fee_budget( + probed_routing_fee=20_000, + config={}, + explicit_max_routing_fee=0, + ) + assert budget == 0 + + +def test_calculate_max_routing_fee_budget_explicit_positive(): + """Verify that explicit positive fee overrides all leeway calculations.""" + budget = swap_out_loop.calculate_max_routing_fee_budget( + probed_routing_fee=20_000, + config={}, + explicit_max_routing_fee=35_000, + ) + assert budget == 35_000 + + +def test_calculate_max_routing_fee_budget_custom_config(): + """Verify custom config settings for fee_leeway_pct and fee_leeway_base_sats.""" + import configparser + cfg = configparser.ConfigParser() + cfg.add_section("loop") + cfg.set("loop", "fee_leeway_pct", "50") + cfg.set("loop", "fee_leeway_base_sats", "1000") + + budget = swap_out_loop.calculate_max_routing_fee_budget( + probed_routing_fee=20_000, + config=cfg, + ) + # 20,000 * 1.5 + 1000 = 31,000 + assert budget == 31_000 + + +def test_calculate_max_routing_fee_budget_cli_leeway_override(): + """Verify that explicit_leeway_pct overrides config.""" + budget = swap_out_loop.calculate_max_routing_fee_budget( + probed_routing_fee=20_000, + config={}, + explicit_leeway_pct=200.0, + ) + # 20,000 * 3.0 + 500 = 60,500 + assert budget == 60_500