diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 109be1d..3f27a62 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,22 +2,22 @@ name: Tests on: push: - branches: [ main ] + branches: [ "**" ] pull_request: - branches: [ main ] + branches: [ main, dev ] jobs: test: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -29,4 +29,4 @@ jobs: - name: Run tests run: | - pytest tests/ + pytest tests/ -v diff --git a/Other/fee_adjuster.md b/Other/fee_adjuster.md index ae8706d..03c08cb 100644 --- a/Other/fee_adjuster.md +++ b/Other/fee_adjuster.md @@ -17,10 +17,28 @@ and local liquidity using data from the Amboss API and LNDg API. - stuck_channel_adjustment: (Optional) Gradually reduces fees for channels without recent forwards. - enabled: true/false. - stuck_time_period: Number of days defining one 'stuck period' interval (e.g., 7). - - min_local_balance_for_stuck_discount: (Optional) If the peer's aggregate local balance ratio is below this threshold (e.g., 0.2 for 20%), the stuck discount will not be applied. - - min_updates_for_discount: (Optional) If the channel's `num_updates` is below this threshold, the fee band discount will not be applied. This is useful to prevent applying a discount to a newly opened channel. +- inbound_protection: (Optional) Configures protection against unprofitable rebalance feedback loops. + - enabled: true/false. + - max_inbound_discount_ppm: Hard cap on negative inbound fee discounts (default: 250 ppm). + - lock_ar_out_target_on_discount: Automatically sets ar_out_target = 100% when an inbound discount is active to prevent rebalance drainage. + - default_restored_ar_out_target: Restores ar_out_target (e.g. 75%) once channel balance is restored and discount is removed. + - restore_liquidity_threshold: Local liquidity ratio required before unlocking (default: 75.0%). + +### Inbound Discount Protection & Rebalance Guard: +When inbound discounts are offered on depleted channels, `fee_adjuster.py` and `rebalance_guard.py` ensure: +1. Inbound discounts are capped at `max_inbound_discount_ppm` to prevent routing payments at net losses against exit channels. +2. The channel is locked (`ar_out_target = 100%`) while offering discounts so LNDg will not cannibalize refilling liquidity as a rebalance donor. +3. When local liquidity recovers and discounts are lifted, standard `ar_out_target` settings are restored. -### Groups and group_adjustment_percentage: +### Standalone Rebalance Guard Tool: +`rebalance_guard.py` audits all open channels in LNDg across both native `af.py` and `fee_adjuster.py` channels: +```bash +# Dry run check +python3 Other/rebalance_guard.py --dry-run + +# Live execution +python3 Other/rebalance_guard.py +``` Allows tailored fee strategies for nodes in specific categories (e.g., "sink", "expensive"). ### Fee Bands: diff --git a/Other/fee_adjuster.py b/Other/fee_adjuster.py index 990d857..9d0bb00 100644 --- a/Other/fee_adjuster.py +++ b/Other/fee_adjuster.py @@ -108,6 +108,13 @@ def __init__(self, message, status_code=None, response_data=None): # Get the path to the parent directory parent_dir = os.path.dirname(os.path.abspath(__file__)) +if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + +try: + from rebalance_guard import audit_channel_rebalance_targets, update_lndg_channel_target +except ImportError: + from Other.rebalance_guard import audit_channel_rebalance_targets, update_lndg_channel_target # Construct the path to the config.ini file config_file_path = os.path.join(parent_dir, "..", "config.ini") @@ -527,6 +534,9 @@ def fetch_all_channels(config): fees_updated = result.get("fees_updated", "") auto_fees = result.get("auto_fees", False) ar_max_cost = result.get("ar_max_cost") + ar_out_target = result.get("ar_out_target", 100) + ar_in_target = result.get("ar_in_target", 90) + auto_rebalance = result.get("auto_rebalance", False) local_inbound_fee_rate = result.get("local_inbound_fee_rate") num_updates = result.get("num_updates", 0) @@ -555,6 +565,9 @@ def fetch_all_channels(config): "local_fee_rate": local_fee_rate, "auto_fees": auto_fees, "ar_max_cost": ar_max_cost, + "ar_out_target": ar_out_target, + "ar_in_target": ar_in_target, + "auto_rebalance": auto_rebalance, "local_inbound_fee_rate": local_inbound_fee_rate, "num_updates": num_updates, } @@ -586,7 +599,10 @@ def get_channels_to_modify(pubkey, config): def calculate_inbound_fee_discount_ppm( - calculated_final_outgoing_fee_ppm, initial_raw_band, ar_max_cost_percent + calculated_final_outgoing_fee_ppm, + initial_raw_band, + ar_max_cost_percent, + max_inbound_discount_ppm=None, ): """ Calculates the inbound fee discount in PPM. @@ -617,9 +633,93 @@ def calculate_inbound_fee_discount_ppm( -calculated_final_outgoing_fee_ppm ) # Max possible discount to make effective fee 0 + # Apply max_inbound_discount_ppm safety cap if specified + if max_inbound_discount_ppm is not None and max_inbound_discount_ppm > 0: + if abs(inbound_fee_discount_ppm) > max_inbound_discount_ppm: + inbound_fee_discount_ppm = -max_inbound_discount_ppm + return inbound_fee_discount_ppm +baseline_file_path = os.path.join(parent_dir, "..", "data", "rebalance_targets_baseline.json") + + +def load_baseline_targets(): + """Load persistent channel baseline targets map.""" + if os.path.exists(baseline_file_path): + try: + with open(baseline_file_path, "r") as f: + return json.load(f) + except Exception as e: + logging.error(f"Error reading baseline targets file: {e}") + return {} + + +def save_baseline_targets(baseline_map): + """Save persistent channel baseline targets map.""" + try: + os.makedirs(os.path.dirname(baseline_file_path), exist_ok=True) + with open(baseline_file_path, "w") as f: + json.dump(baseline_map, f, indent=4) + except Exception as e: + logging.error(f"Error saving baseline targets file: {e}") + + +def determine_ar_out_target_update( + channel_data, + new_inbound_fee_ppm, + inbound_protection_config=None, + baseline_map=None, + chan_id=None, +): + """ + Determines if ar_out_target needs to be updated to prevent rebalancing feedback loops. + + - If new_inbound_fee_ppm < 0 (inbound discount offered) and current ar_out_target < lock_target: + locks target to lock_target (default 100) to prevent channel from being used as an outbound rebalance source. + - If new_inbound_fee_ppm >= 0, local liquidity >= restore_threshold (default 75%), + and current ar_out_target >= lock_target: + restores ar_out_target to baseline (channel specific baseline or default 75). + + Returns: + int: New target value (e.g. 100 or baseline) if update needed, else None. + """ + if inbound_protection_config is None: + inbound_protection_config = {} + + lock_enabled = inbound_protection_config.get("lock_ar_out_target_on_discount", True) + if not lock_enabled: + return None + + lock_target = inbound_protection_config.get("lock_target", 100) + default_restored_target = inbound_protection_config.get("default_restored_ar_out_target", 75) + restore_threshold = inbound_protection_config.get("restore_liquidity_threshold", 75.0) + + current_out_target = channel_data.get("ar_out_target") + if current_out_target is None: + current_out_target = 100 + + local_balance_ratio = channel_data.get("local_balance_ratio", 0) + chan_key = str(chan_id) if chan_id else str(channel_data.get("chan_id", "")) + + if new_inbound_fee_ppm < 0: + if current_out_target < lock_target: + if baseline_map is not None and chan_key and chan_key not in baseline_map: + baseline_map[chan_key] = current_out_target + return lock_target + elif new_inbound_fee_ppm >= 0: + if current_out_target >= lock_target: + if baseline_map and chan_key in baseline_map: + channel_restore_target = baseline_map[chan_key] + if channel_restore_target < lock_target: + # Dynamic Hysteresis: Require local balance to reach max(baseline + 15%, 60%) + dynamic_threshold = min(max(channel_restore_target + 15.0, 60.0), 95.0) + if local_balance_ratio >= dynamic_threshold and current_out_target != channel_restore_target: + return channel_restore_target + + return None + + # Write to LNDg def update_lndg_fee( chan_id, @@ -627,6 +727,7 @@ def update_lndg_fee( new_inbound_fee_rate_ppm, channel_data, config, + new_ar_out_target=None, log_api_response=False, ): lndg_api_url = config["lndg"]["lndg_api_url"] @@ -634,20 +735,26 @@ def update_lndg_fee( password = config["credentials"]["lndg_password"] timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # First, update auto_fees if needed (for outgoing) - if channel_data["auto_fees"]: + # Update channel settings (auto_fees or ar_out_target) if needed + channel_update_payload = {} + if channel_data.get("auto_fees"): + channel_update_payload["auto_fees"] = False + if new_ar_out_target is not None and new_ar_out_target != channel_data.get("ar_out_target"): + channel_update_payload["ar_out_target"] = new_ar_out_target + + if channel_update_payload: + channel_update_payload["chan_id"] = chan_id auto_fees_url = f"{lndg_api_url}/api/channels/{chan_id}/" - auto_fees_payload = {"chan_id": chan_id, "auto_fees": False} try: response = requests.put( - auto_fees_url, json=auto_fees_payload, auth=(username, password) + auto_fees_url, json=channel_update_payload, auth=(username, password) ) response.raise_for_status() logging.info( - f"{timestamp}: Disabled auto_fees for channel {chan_id} (for outgoing)" + f"{timestamp}: Updated channel settings for {chan_id}: {json.dumps(channel_update_payload)}" ) except requests.exceptions.RequestException as e: - logging.error(f"Error updating auto_fees for channel {chan_id}: {e}") + logging.error(f"Error updating channel settings for {chan_id}: {e}") # Continue to fee policy update even if this fails # Then, update the fee policy (outgoing and inbound) @@ -1130,6 +1237,77 @@ def main(): lndg_fee_update_enabled = False skip_charge_lnd_file_write = True + # --- Global Rebalance Guard Audit (Audits all open channels across LNDg) --- + inbound_protection = node_definitions.get("inbound_protection", {}) + if inbound_protection.get("lock_ar_out_target_on_discount", True): + lock_target = inbound_protection.get("lock_target", 100) + restore_target = inbound_protection.get("default_restored_ar_out_target", 75) + restore_threshold = inbound_protection.get("restore_liquidity_threshold", 75.0) + baseline_map = load_baseline_targets() + + # Pre-fetch and cache all open channels from LNDg + cached_channels = fetch_all_channels(config) + all_channels_list = [] + for pub, chans in cached_channels.items(): + for c_id, c_data in chans.items(): + c_dict = dict(c_data, chan_id=c_id) + all_channels_list.append(c_dict) + + guard_plans = audit_channel_rebalance_targets( + all_channels_list, + lock_target=lock_target, + restore_target=restore_target, + restore_liquidity_threshold=restore_threshold, + baseline_map=baseline_map, + ) + + if guard_plans and (terminal_output_enabled or args.debug): + table = PrettyTable() + table.field_names = [ + "Action", + "Chan ID", + "Alias", + "Local %", + "Out Fee", + "In Fee", + "oTarget", + "New Target", + "Managed By", + ] + for p in guard_plans: + managed = "LNDg af.py" if p.get("auto_fees") else "fee_adjuster" + action_str = f"🔒 {p['action']}" if p["action"] == "LOCK" else f"🔓 {p['action']}" + table.add_row([ + action_str, + str(p["chan_id"])[:12] + "...", + str(p["alias"])[:18], + f"{p['local_ratio']:.1f}%", + f"{p['outbound_fee']} ppm", + f"{p['inbound_fee']} ppm", + f"{p['old_target']}%", + f"{p['new_target']}%", + managed, + ]) + + print("=" * 80, flush=True) + print(" 🛡️ Global Rebalance Guard Audit (All Open Channels)", flush=True) + print(f" Mode: {'SIMULATION / DEBUG' if args.debug else 'LIVE EXECUTION'}", flush=True) + print(f" Lock Target: {lock_target}% | Restore Target: {restore_target}%", flush=True) + print("=" * 80, flush=True) + print(table, flush=True) + print(f"Total Rebalance Guard adjustments: {len(guard_plans)} channels\n", flush=True) + + if not args.debug and lndg_fee_update_enabled and guard_plans: + for p in guard_plans: + update_lndg_channel_target( + p["chan_id"], + p["new_target"], + config, + dry_run=False + ) + save_baseline_targets(baseline_map) + logging.info(f"RebalanceGuard applied {len(guard_plans)} target adjustments.") + for node in node_definitions["nodes"]: pubkey = node["pubkey"] group_name = node.get("group") @@ -1348,6 +1526,12 @@ def main(): # --- Inbound Fee Calculation (per peer, but uses channel's ar_max_cost if different) --- # For aggregated peers, this assumes ar_max_cost would be similar or we'd use first channel's. # The current loop is per-channel for updates, so this fits. + inbound_protection_config = node_definitions.get("inbound_protection", {}) + max_inbound_discount_ppm = fee_conditions.get( + "max_inbound_discount_ppm", + inbound_protection_config.get("max_inbound_discount_ppm", None), + ) + baseline_map = load_baseline_targets() calculated_inbound_ppm_for_peer = 0 @@ -1357,7 +1541,10 @@ def main(): if first_chan_ar_max_cost is not None: calculated_inbound_ppm_for_peer = ( calculate_inbound_fee_discount_ppm( - final_rate, initial_raw_band, first_chan_ar_max_cost + final_rate, + initial_raw_band, + first_chan_ar_max_cost, + max_inbound_discount_ppm=max_inbound_discount_ppm, ) ) @@ -1377,14 +1564,30 @@ def main(): ): current_chan_calculated_inbound_ppm = ( calculate_inbound_fee_discount_ppm( - final_rate, initial_raw_band, chan_ar_max_cost + final_rate, + initial_raw_band, + chan_ar_max_cost, + max_inbound_discount_ppm=max_inbound_discount_ppm, ) ) + # Check for ar_out_target update to prevent rebalance feedback loops + new_ar_out_target = determine_ar_out_target_update( + channel_data, + current_chan_calculated_inbound_ppm, + inbound_protection_config=inbound_protection_config, + baseline_map=baseline_map, + chan_id=chan_id, + ) + # Determine if an update to LNDg is needed based on deltas should_update_lndg_for_this_channel = False update_decision_reason = "delta<=threshold" + if new_ar_out_target is not None: + should_update_lndg_for_this_channel = True + update_decision_reason = f"ar_out_target->{new_ar_out_target}%" + # Emit a compact calc summary line before decision band_names_short = ["D+", "D", "N", "P", "P+"] calc_ctx = { @@ -1461,7 +1664,7 @@ def main(): if lndg_fee_update_enabled and should_update_lndg_for_this_channel: try: logging.info( - "UpdateDecision | chan_id=%s final=%d current=%d delta=%d threshold=%d reason=%s inbound_check=%s", + "UpdateDecision | chan_id=%s final=%d current=%d delta=%d threshold=%d reason=%s inbound_check=%s new_ar_target=%s", chan_id, final_rate, current_outbound_fee_on_channel, @@ -1469,6 +1672,7 @@ def main(): fee_delta_threshold, update_decision_reason, "enabled" if inbound_auto_fee_enabled_for_node else "disabled", + str(new_ar_out_target), ) update_lndg_fee( chan_id, @@ -1476,9 +1680,12 @@ def main(): current_chan_calculated_inbound_ppm, channel_data, config, + new_ar_out_target=new_ar_out_target, log_api_response=True, ) updated_any_channel = True + if new_ar_out_target is not None: + save_baseline_targets(baseline_map) except LNDGAPIError as api_err: logging.error( f"Failed LNDg update for {chan_id}: {api_err}" diff --git a/Other/rebalance_guard.py b/Other/rebalance_guard.py new file mode 100644 index 0000000..0eec430 --- /dev/null +++ b/Other/rebalance_guard.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Rebalance Guard Script + +Audits all active channels in LNDg to protect against unprofitable rebalancing feedback loops: +1. Channels offering inbound discounts (local_inbound_fee_rate < 0) are locked (ar_out_target = 100%) + to prevent LNDg from using them as outbound rebalance donors while they are refilling. +2. Balanced channels (local_inbound_fee_rate >= 0 and local_balance_ratio >= threshold) that were + previously locked are restored to their standard ar_out_target (e.g. 75%). + +Usage: + python3 rebalance_guard.py [--dry-run] [--debug] +""" + +import os +import sys +import argparse +import logging +import json +import configparser +import requests +from prettytable import PrettyTable + +# Get the path to the parent directory +parent_dir = os.path.dirname(os.path.abspath(__file__)) +config_file_path = os.path.join(parent_dir, "..", "config.ini") +fee_config_file_path = os.path.join(parent_dir, "..", "feeConfig.json") +log_file_path = os.path.join(parent_dir, "..", "logs", "rebalance-guard.log") + +# Setup logging +logging.basicConfig( + filename=log_file_path, + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s" +) + + +baseline_file_path = os.path.join(parent_dir, "..", "data", "rebalance_targets_baseline.json") + + +def load_config(): + config = configparser.ConfigParser() + config.read(config_file_path) + return config + + +def load_fee_config(): + if os.path.exists(fee_config_file_path): + with open(fee_config_file_path, "r") as f: + return json.load(f) + return {} + + +def load_baseline_targets(): + """Load persistent channel baseline targets map.""" + if os.path.exists(baseline_file_path): + try: + with open(baseline_file_path, "r") as f: + return json.load(f) + except Exception as e: + logging.error(f"Error reading baseline targets file: {e}") + return {} + + +def save_baseline_targets(baseline_map): + """Save persistent channel baseline targets map.""" + try: + os.makedirs(os.path.dirname(baseline_file_path), exist_ok=True) + with open(baseline_file_path, "w") as f: + json.dump(baseline_map, f, indent=4) + except Exception as e: + logging.error(f"Error saving baseline targets file: {e}") + + +def fetch_all_open_channels(config): + """Fetch all open channels from LNDg API.""" + lndg_api_url = config["lndg"]["lndg_api_url"] + username = config["credentials"]["lndg_username"] + password = config["credentials"]["lndg_password"] + api_url = f"{lndg_api_url}/api/channels/?limit=1500" + + response = requests.get(api_url, auth=(username, password), timeout=15) + response.raise_for_status() + data = response.json() + results = data.get("results", []) + return [c for c in results if c.get("is_open", False)] + + +def evaluate_channel_action( + channel, + lock_target=100, + restore_target=75, + restore_liquidity_threshold=75.0, + lock_threshold=95, + baseline_map=None +): + """ + Evaluates whether a channel needs ar_out_target update. + + Returns: + tuple: (action: str, new_target: int|None) + action in ["LOCK", "RESTORE", "NOOP"] + """ + chan_id = str(channel.get("chan_id", "")) + inbound_fee = channel.get("local_inbound_fee_rate") or 0 + current_out_target = channel.get("ar_out_target") + if current_out_target is None: + current_out_target = 100 + + capacity = channel.get("capacity", 0) + local_balance = channel.get("local_balance", 0) + local_ratio = (local_balance / capacity * 100.0) if capacity > 0 else 0.0 + + # Rule 1: Inbound discount active -> lock out from rebalancing donor candidacy + if inbound_fee < 0: + if current_out_target < lock_threshold: + # Capture current target as baseline before locking if not already captured + if baseline_map is not None and chan_id and chan_id not in baseline_map: + baseline_map[chan_id] = current_out_target + return "LOCK", lock_target + + # Rule 2: Inbound discount removed & liquidity healthy -> restore baseline target + elif inbound_fee >= 0: + if current_out_target >= lock_threshold: + if baseline_map and chan_id in baseline_map: + channel_restore_target = baseline_map[chan_id] + if channel_restore_target < lock_threshold: + # Dynamic Hysteresis: Require local balance to reach max(baseline + 15%, 60%) + dynamic_threshold = min(max(channel_restore_target + 15.0, 60.0), 95.0) + if local_ratio >= dynamic_threshold and current_out_target != channel_restore_target: + return "RESTORE", channel_restore_target + + return "NOOP", None + + +def audit_channel_rebalance_targets( + channels, + lock_target=100, + restore_target=75, + restore_liquidity_threshold=75.0, + lock_threshold=95, + baseline_map=None +): + """ + Audits a list of open channels and generates update plans. + + Returns: + list of dicts containing audit actions. + """ + plans = [] + if baseline_map is None: + baseline_map = {} + + for c in channels: + action, new_target = evaluate_channel_action( + c, + lock_target=lock_target, + restore_target=restore_target, + restore_liquidity_threshold=restore_liquidity_threshold, + lock_threshold=lock_threshold, + baseline_map=baseline_map + ) + if action != "NOOP": + capacity = c.get("capacity", 0) + local_balance = c.get("local_balance", 0) + local_ratio = (local_balance / capacity * 100.0) if capacity > 0 else 0.0 + plans.append({ + "chan_id": str(c.get("chan_id")), + "alias": c.get("alias", ""), + "action": action, + "old_target": c.get("ar_out_target", 100), + "new_target": new_target, + "inbound_fee": c.get("local_inbound_fee_rate", 0) or 0, + "outbound_fee": c.get("local_fee_rate", 0) or 0, + "local_ratio": local_ratio, + "auto_fees": c.get("auto_fees", False), + }) + return plans + + +def update_lndg_channel_target(chan_id, new_target, config, dry_run=False): + """Update ar_out_target on LNDg via REST API.""" + if dry_run: + logging.info(f"[DRY RUN] Would update channel {chan_id} ar_out_target to {new_target}") + return True + + lndg_api_url = config["lndg"]["lndg_api_url"] + username = config["credentials"]["lndg_username"] + password = config["credentials"]["lndg_password"] + url = f"{lndg_api_url}/api/channels/{chan_id}/" + payload = {"chan_id": chan_id, "ar_out_target": new_target} + + try: + response = requests.put(url, json=payload, auth=(username, password), timeout=10) + response.raise_for_status() + logging.info(f"Successfully updated channel {chan_id} ar_out_target to {new_target}") + return True + except Exception as e: + logging.error(f"Failed to update channel {chan_id} target: {e}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="Audit and guard LNDg rebalancing targets.") + parser.add_argument("--dry-run", action="store_true", help="Print actions without modifying LNDg") + parser.add_argument("--debug", action="store_true", help="Enable verbose debug logging") + parser.add_argument("--lock-target", type=int, default=100, help="Target percentage when locked (default: 100)") + parser.add_argument("--restore-target", type=int, default=75, help="Target percentage when restored (default: 75)") + parser.add_argument("--restore-threshold", type=float, default=75.0, help="Min local liquidity % to restore target (default: 75.0)") + args = parser.parse_args() + + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + + config = load_config() + fee_config = load_fee_config() + inbound_protection = fee_config.get("inbound_protection", {}) + + lock_target = inbound_protection.get("lock_target", args.lock_target) + restore_target = inbound_protection.get("default_restored_ar_out_target", args.restore_target) + restore_threshold = inbound_protection.get("restore_liquidity_threshold", args.restore_threshold) + + print("=" * 80) + print(" 🛡️ LNDg Rebalance Guard") + print(f" Mode: {'DRY RUN' if args.dry_run else 'LIVE EXECUTION'}") + print(f" Lock Target: {lock_target}% | Restore Target: {restore_target}% | Restore Threshold: {restore_threshold}%") + print("=" * 80) + + try: + channels = fetch_all_open_channels(config) + print(f"Fetched {len(channels)} open channels from LNDg.") + except Exception as e: + print(f"❌ Error fetching channels from LNDg: {e}") + sys.exit(1) + + baseline_map = load_baseline_targets() + + plans = audit_channel_rebalance_targets( + channels, + lock_target=lock_target, + restore_target=restore_target, + restore_liquidity_threshold=restore_threshold, + baseline_map=baseline_map + ) + + if not plans: + print("✅ All channels are healthy! No target adjustments required.") + return + + table = PrettyTable() + table.field_names = [ + "Action", + "Chan ID", + "Alias", + "Local %", + "Out Fee", + "In Fee", + "Current oTarget", + "New oTarget", + "Managed By" + ] + + for p in plans: + managed = "LNDg af.py" if p["auto_fees"] else "fee_adjuster" + action_str = f"🔒 {p['action']}" if p["action"] == "LOCK" else f"🔓 {p['action']}" + table.add_row([ + action_str, + p["chan_id"][:12] + "...", + p["alias"][:18], + f"{p['local_ratio']:.1f}%", + f"{p['outbound_fee']} ppm", + f"{p['inbound_fee']} ppm", + f"{p['old_target']}%", + f"{p['new_target']}%", + managed + ]) + + print(table) + print(f"\nTotal adjustments to apply: {len(plans)} channels") + + success_count = 0 + for p in plans: + success = update_lndg_channel_target( + p["chan_id"], + p["new_target"], + config, + dry_run=args.dry_run + ) + if success: + success_count += 1 + + if not args.dry_run: + save_baseline_targets(baseline_map) + print(f"✅ Successfully updated {success_count}/{len(plans)} channel targets in LNDg.") + else: + print(f"🔍 Dry run complete. {len(plans)} potential updates identified.") + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md index e313be9..fe90c72 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Below is a list of available scripts and their primary functions. Scripts marked **Other:** - `swap_wallet.py`: [one-off] Sends a specified amount of Lightning funds to a given LN address. Allows customization of total amount, amount per transaction, interval between transactions, maximum fee rate, and an optional message for the payments. -- `fee_adjuster.py`: [systemd service, cronjob] Automatically adjusts channel fees based on Amboss API data and user-defined settings. Requires a running LNDg instance to retrieve local channel details. Configure via `feeConfig.json` and `config.ini`. Install using `sudo ./Other/install_fee_adjuster_service.sh` or run as a cron job. +- `fee_adjuster.py`: [systemd service, cronjob] Automatically adjusts channel fees based on Amboss API data and user-defined settings. Includes dynamic inbound fee discounts, fee bands, stuck channel adjustments, global rebalance guard auditing across all 100+ LNDg channels, and Dynamic Hysteresis unlocking. Configure via `feeConfig.json` and `config.ini`. Install using `sudo ./Other/install_fee_adjuster_service.sh` or run as a cron job. +- `rebalance_guard.py`: [command-line output, cronjob] Standalone CLI tool to audit all open LNDg channels across both native Auto-Fees (`af.py`) and `fee_adjuster.py`. Protects refilling channels with active inbound discounts by setting `ar_out_target = 100%` (preventing LNDg from draining them as outbound rebalance donors), and automatically restores baseline targets using **Dynamic Hysteresis** ($\text{threshold} = \min(\max(\text{baseline} + 15\%, 60\%), 95\%)$) once liquidity recovers and inbound discounts are deactivated. Run with `--dry-run` to preview actions. - `boltz_swap-out.py`: [command-line output, one-off] Automates Lightning Network (LN) to Liquid Bitcoin (L-BTC) swaps using Boltz for submarine swaps (swapping out). ### === Installation Instructions === diff --git a/feeConfig.json.example b/feeConfig.json.example index 24a5d2c..23539e1 100644 --- a/feeConfig.json.example +++ b/feeConfig.json.example @@ -1,8 +1,15 @@ { "Terminal_output": true, - "LNDg_fee_update": False, + "LNDg_fee_update": false, "write_charge_lnd_file": true, "update_channel_notes": true, + "inbound_protection": { + "lock_ar_out_target_on_discount": true, + "lock_target": 100, + "default_restored_ar_out_target": 75, + "restore_liquidity_threshold": 75.0, + "max_inbound_discount_ppm": null + }, "groups": { "sink": { "group_adjustment_percentage": 0.10, diff --git a/tests/test_fee_adjuster.py b/tests/test_fee_adjuster.py index ea91f1f..cfd02bb 100644 --- a/tests/test_fee_adjuster.py +++ b/tests/test_fee_adjuster.py @@ -104,4 +104,194 @@ def test_premium_applied_regardless_of_stuck(fee_conditions): assert init_band == 4 assert adj_factor == 1.40 + + +def test_inbound_fee_discount_without_cap(): + """ + Test standard inbound fee discount calculation without max cap. + Band 4 (0-20% local), Outbound Fee = 2000 ppm, ar_max_cost = 75%. + Expected raw discount = -round(2000 * 0.75 * 0.90) = -1350 ppm. + """ + from fee_adjuster import calculate_inbound_fee_discount_ppm + discount = calculate_inbound_fee_discount_ppm( + calculated_final_outgoing_fee_ppm=2000, + initial_raw_band=4, + ar_max_cost_percent=75, + max_inbound_discount_ppm=None + ) + assert discount == -1350 + + +def test_inbound_fee_discount_with_max_cap(): + """ + Test inbound fee discount calculation with max_inbound_discount_ppm cap. + Raw discount would be -1350 ppm, but with max_inbound_discount_ppm = 250, + it must be clamped to -250 ppm. + """ + from fee_adjuster import calculate_inbound_fee_discount_ppm + discount = calculate_inbound_fee_discount_ppm( + calculated_final_outgoing_fee_ppm=2000, + initial_raw_band=4, + ar_max_cost_percent=75, + max_inbound_discount_ppm=250 + ) + assert discount == -250 + + +def test_inbound_fee_discount_within_cap_unchanged(): + """ + Test that discounts smaller than max cap are preserved. + Band 2 (40-60% local), Outbound Fee = 300 ppm, ar_max_cost = 50%. + Raw discount = -round(300 * 0.50 * 0.20) = -30 ppm. + With cap of 250 ppm, discount must remain -30 ppm. + """ + from fee_adjuster import calculate_inbound_fee_discount_ppm + discount = calculate_inbound_fee_discount_ppm( + calculated_final_outgoing_fee_ppm=300, + initial_raw_band=2, + ar_max_cost_percent=50, + max_inbound_discount_ppm=250 + ) + assert discount == -30 + + +def test_inbound_fee_discount_high_liquidity_is_zero(): + """ + Test that Band 0 and Band 1 (high local liquidity) never receive inbound discounts. + """ + from fee_adjuster import calculate_inbound_fee_discount_ppm + assert calculate_inbound_fee_discount_ppm(2000, 0, 75, 250) == 0 + assert calculate_inbound_fee_discount_ppm(2000, 1, 75, 250) == 0 + + +def test_determine_ar_out_target_update_locks_on_discount(): + """ + Test that when a negative inbound fee is set and current ar_out_target < 100, + the target is locked to 100% to prevent rebalancer drain. + """ + from fee_adjuster import determine_ar_out_target_update + channel_data = { + "ar_out_target": 45, + "local_balance_ratio": 15.0 + } + inbound_protection = { + "enabled": True, + "lock_ar_out_target_on_discount": True, + "default_restored_ar_out_target": 75 + } + new_target = determine_ar_out_target_update(channel_data, new_inbound_fee_ppm=-250, inbound_protection_config=inbound_protection) + assert new_target == 100 + + +def test_determine_ar_out_target_update_restores_when_balanced(): + """ + Test that when inbound discount is removed and local balance is high (>= 90% for baseline 75), + a locked ar_out_target (100) is restored to its recorded baseline. + """ + from fee_adjuster import determine_ar_out_target_update + channel_data = { + "ar_out_target": 100, + "local_balance_ratio": 92.0 + } + inbound_protection = { + "lock_ar_out_target_on_discount": True, + "default_restored_ar_out_target": 75 + } + baseline_map = {"chan_123": 75} + new_target = determine_ar_out_target_update( + channel_data, + new_inbound_fee_ppm=0, + inbound_protection_config=inbound_protection, + baseline_map=baseline_map, + chan_id="chan_123" + ) + assert new_target == 75 + + +def test_determine_ar_out_target_update_ignores_unmanaged_100(): + """ + Test that an intentionally 100% channel (e.g. bfx-lnd0) without a lower baseline is not modified. + """ + from fee_adjuster import determine_ar_out_target_update + channel_data = { + "ar_out_target": 100, + "local_balance_ratio": 85.0 + } + inbound_protection = { + "lock_ar_out_target_on_discount": True, + "default_restored_ar_out_target": 75 + } + new_target = determine_ar_out_target_update( + channel_data, + new_inbound_fee_ppm=0, + inbound_protection_config=inbound_protection, + baseline_map={}, + chan_id="bfx_0" + ) + assert new_target is None + + + +def test_determine_ar_out_target_update_no_change_needed(): + """ + Test that if channel already has appropriate target, no update is requested (returns None). + """ + from fee_adjuster import determine_ar_out_target_update + channel_data = { + "ar_out_target": 100, + "local_balance_ratio": 15.0 + } + inbound_protection = { + "enabled": True, + "lock_ar_out_target_on_discount": True, + "default_restored_ar_out_target": 75 + } + # Already 100 while discounted -> None + assert determine_ar_out_target_update(channel_data, new_inbound_fee_ppm=-250, inbound_protection_config=inbound_protection) is None + + +def test_determine_ar_out_target_update_disabled_lock(): + """ + Test that if lock_ar_out_target_on_discount is False, no locking action is taken. + """ + from fee_adjuster import determine_ar_out_target_update + channel_data = { + "ar_out_target": 45, + "local_balance_ratio": 15.0 + } + inbound_protection = { + "lock_ar_out_target_on_discount": False, + "default_restored_ar_out_target": 75 + } + assert determine_ar_out_target_update(channel_data, new_inbound_fee_ppm=-250, inbound_protection_config=inbound_protection) is None + + +def test_determine_ar_out_target_update_restores_custom_baseline(): + """ + Test that when a channel has a saved baseline in baseline_map (e.g. 45%), + it restores to 45% instead of generic default (e.g. 75%). + """ + from fee_adjuster import determine_ar_out_target_update + channel_data = { + "ar_out_target": 100, + "local_balance_ratio": 85.0 + } + inbound_protection = { + "lock_ar_out_target_on_discount": True, + "default_restored_ar_out_target": 75 + } + baseline_map = { + "1026788829343318018": 45 + } + new_target = determine_ar_out_target_update( + channel_data, + new_inbound_fee_ppm=0, + inbound_protection_config=inbound_protection, + baseline_map=baseline_map, + chan_id="1026788829343318018" + ) + assert new_target == 45 + + + diff --git a/tests/test_rebalance_guard.py b/tests/test_rebalance_guard.py new file mode 100644 index 0000000..0d5ec88 --- /dev/null +++ b/tests/test_rebalance_guard.py @@ -0,0 +1,156 @@ +import pytest +from rebalance_guard import audit_channel_rebalance_targets, evaluate_channel_action + +def test_evaluate_channel_action_locks_discounted_channel(): + """ + If a channel has a negative inbound fee and ar_out_target < 95%, + it must be flagged to be locked to 100%. + """ + channel = { + "chan_id": "1026788829343318018", + "alias": "Garlic🧄", + "local_inbound_fee_rate": -410, + "ar_out_target": 35, + "local_balance": 1704000, + "capacity": 3000000, + "auto_rebalance": False + } + action, target = evaluate_channel_action(channel, lock_target=100, restore_target=75, restore_liquidity_threshold=75.0) + assert action == "LOCK" + assert target == 100 + +def test_evaluate_channel_action_restores_balanced_channel(): + """ + If a channel has no inbound discount (>=0), local liquidity >= 75%, + and its ar_out_target is currently locked at 100% with a recorded baseline, it should be restored. + """ + channel = { + "chan_id": "1046355738178945025", + "alias": "Volarte⚡", + "local_inbound_fee_rate": 0, + "ar_out_target": 100, + "local_balance": 4900000, + "capacity": 5000000, # 98% + "auto_rebalance": False + } + baseline_map = {"1046355738178945025": 75} + action, target = evaluate_channel_action( + channel, + lock_target=100, + restore_target=75, + restore_liquidity_threshold=75.0, + baseline_map=baseline_map + ) + assert action == "RESTORE" + assert target == 75 + +def test_evaluate_channel_action_ignores_unmanaged_100_percent_channel(): + """ + If a channel has ar_out_target = 100% intentionally (e.g. bfx-lnd0) + and was not locked by the guard (not in baseline_map), it must remain NOOP. + """ + channel = { + "chan_id": "1012176319780093953", + "alias": "bfx-lnd0", + "local_inbound_fee_rate": 0, + "ar_out_target": 100, + "local_balance": 4000000, + "capacity": 5000000, # 80% + "auto_rebalance": False + } + action, target = evaluate_channel_action(channel, lock_target=100, restore_target=75, restore_liquidity_threshold=75.0, baseline_map={}) + assert action == "NOOP" + assert target is None + +def test_evaluate_channel_action_no_op_for_healthy_channel(): + """ + If a channel has normal settings and no discount, no action is taken. + """ + channel = { + "chan_id": "996248794333052929", + "alias": "Play-asia.com", + "local_inbound_fee_rate": 0, + "ar_out_target": 65, + "local_balance": 4500000, + "capacity": 5000000, + "auto_rebalance": False + } + action, target = evaluate_channel_action(channel, lock_target=100, restore_target=75, restore_liquidity_threshold=75.0) + assert action == "NOOP" + assert target is None + +def test_audit_channel_rebalance_targets(): + """ + Test auditing a batch of channels. + """ + channels = [ + { + "chan_id": "1", + "alias": "Garlic🧄", + "local_inbound_fee_rate": -410, + "ar_out_target": 35, + "local_balance": 1704000, + "capacity": 3000000, + "auto_rebalance": False, + "is_open": True + }, + { + "chan_id": "2", + "alias": "HealthyNode", + "local_inbound_fee_rate": 0, + "ar_out_target": 65, + "local_balance": 4500000, + "capacity": 5000000, + "auto_rebalance": False, + "is_open": True + }, + { + "chan_id": "3", + "alias": "RestorableNode", + "local_inbound_fee_rate": 0, + "ar_out_target": 100, + "local_balance": 4800000, + "capacity": 5000000, + "auto_rebalance": False, + "is_open": True + } + ] + baseline_map = {"3": 75} + plans = audit_channel_rebalance_targets(channels, lock_target=100, restore_target=75, baseline_map=baseline_map) + assert len(plans) == 2 + assert plans[0]["chan_id"] == "1" + assert plans[0]["action"] == "LOCK" + assert plans[0]["new_target"] == 100 + + assert plans[1]["chan_id"] == "3" + assert plans[1]["action"] == "RESTORE" + assert plans[1]["new_target"] == 75 + + +def test_evaluate_channel_action_restores_custom_channel_baseline(): + """ + Test that when a baseline map contains a custom target (e.g. 35 for Garlic), + it restores to 35 instead of generic default 75. + """ + channel = { + "chan_id": "1026788829343318018", + "alias": "Garlic🧄", + "local_inbound_fee_rate": 0, + "ar_out_target": 100, + "local_balance": 2700000, + "capacity": 3000000, # 90% + "auto_rebalance": False + } + baseline_map = { + "1026788829343318018": 35 + } + action, target = evaluate_channel_action( + channel, + lock_target=100, + restore_target=75, + restore_liquidity_threshold=75.0, + baseline_map=baseline_map + ) + assert action == "RESTORE" + assert target == 35 +