Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Other/swap_out-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down Expand Up @@ -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. |
Expand Down
70 changes: 64 additions & 6 deletions Other/swap_out-loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions config.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 57 additions & 0 deletions tests/Other/test_swap_out_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading