Files
lnflow/lightning_policy.py
2025-07-22 18:51:45 +02:00

508 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Lightning Policy Manager - Improved charge-lnd with Advanced Inbound Fees
A modern, intelligent fee management system that combines the flexibility of charge-lnd
with advanced inbound fee strategies, machine learning, and automatic rollbacks.
Key improvements over charge-lnd:
- Advanced inbound fee strategies (not just discounts)
- Automatic performance tracking and rollbacks
- Revenue optimization focus
- Data-driven policy learning
- Integrated safety mechanisms
- SQLite database for historical analysis
"""
import asyncio
import logging
import sys
import json
import os
from pathlib import Path
from datetime import datetime
import click
from tabulate import tabulate
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from src.policy.manager import PolicyManager
from src.policy.engine import create_sample_config
def setup_logging(verbose: bool = False):
"""Setup logging configuration"""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('policy.log'),
logging.StreamHandler(sys.stderr)
]
)
@click.group()
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose logging')
@click.option('--lnd-manage-url', default=os.getenv('LND_MANAGE_URL', 'http://localhost:8081'), help='LND Manage API URL')
@click.option('--lnd-rest-url', default=os.getenv('LND_REST_URL', 'https://localhost:8080'), help='LND REST API URL')
@click.option('--lnd-grpc-host', default=os.getenv('LND_GRPC_HOST', 'localhost:10009'), help='LND gRPC endpoint (preferred)')
@click.option('--lnd-dir', default=os.getenv('LND_DIR', '~/.lnd'), help='LND directory path')
@click.option('--prefer-grpc/--prefer-rest', default=os.getenv('PREFER_GRPC', 'true').lower() == 'true', help='Prefer gRPC over REST API (faster)')
@click.option('--config', '-c', type=click.Path(exists=True), help='Policy configuration file')
@click.pass_context
def cli(ctx, verbose, lnd_manage_url, lnd_rest_url, lnd_grpc_host, lnd_dir, prefer_grpc, config):
"""Lightning Policy Manager - Advanced fee management with inbound fees"""
setup_logging(verbose)
ctx.ensure_object(dict)
# Only initialize manager if config is provided
if config:
ctx.obj['manager'] = PolicyManager(
config_file=config,
lnd_manage_url=lnd_manage_url,
lnd_rest_url=lnd_rest_url,
lnd_grpc_host=lnd_grpc_host,
lnd_dir=lnd_dir,
prefer_grpc=prefer_grpc
)
ctx.obj['verbose'] = verbose
ctx.obj['lnd_manage_url'] = lnd_manage_url
ctx.obj['lnd_rest_url'] = lnd_rest_url
ctx.obj['lnd_grpc_host'] = lnd_grpc_host
ctx.obj['prefer_grpc'] = prefer_grpc
@cli.command()
@click.option('--dry-run', is_flag=True, help='Show what would be changed without applying')
@click.option('--macaroon-path', default=os.getenv('LND_MACAROON_PATH', '/root/.lnd/data/chain/bitcoin/mainnet/admin.macaroon'), help='Path to admin.macaroon file')
@click.option('--cert-path', default=os.getenv('LND_CERT_PATH', '/root/.lnd/tls.cert'), help='Path to tls.cert file')
@click.pass_context
def apply(ctx, dry_run, macaroon_path, cert_path):
"""Apply policy-based fee changes to all channels"""
manager = ctx.obj.get('manager')
if not manager:
click.echo("Error: Configuration file required. Use -c/--config option.")
return
async def _apply():
if dry_run:
print("DRY-RUN MODE: Showing policy recommendations without applying changes")
else:
protocol = "gRPC" if ctx.obj.get('prefer_grpc', True) else "REST"
print(f"Applying policy-based fee changes via {protocol} API...")
results = await manager.apply_policies(
dry_run=dry_run,
macaroon_path=macaroon_path,
cert_path=cert_path
)
# Print summary
print(f"\n=== POLICY APPLICATION RESULTS ===")
print(f"Channels processed: {results['channels_processed']}")
print(f"Policies applied: {results['policies_applied']}")
print(f"Fee changes: {results['fee_changes']}")
print(f"Errors: {len(results['errors'])}")
if results['errors']:
print(f"\n=== ERRORS ===")
for error in results['errors'][:5]: # Show first 5 errors
print(f"{error}")
if len(results['errors']) > 5:
print(f"... and {len(results['errors']) - 5} more errors")
# Show policy matches
if results['policy_matches']:
print(f"\n=== POLICY MATCHES (Top 10) ===")
matches_table = []
for channel_id, policies in list(results['policy_matches'].items())[:10]:
matches_table.append([
channel_id[:16] + "...",
', '.join(policies)
])
print(tabulate(matches_table, headers=["Channel", "Matched Policies"], tablefmt="grid"))
# Show performance summary
perf_summary = results['performance_summary']
if perf_summary.get('policy_performance'):
print(f"\n=== POLICY PERFORMANCE ===")
perf_table = []
for policy in perf_summary['policy_performance']:
perf_table.append([
policy['name'],
policy['applied_count'],
policy['strategy'],
f"{policy['avg_revenue_impact']:.0f} msat"
])
print(tabulate(perf_table,
headers=["Policy", "Applied", "Strategy", "Avg Revenue Impact"],
tablefmt="grid"))
asyncio.run(_apply())
@cli.command()
@click.pass_context
def status(ctx):
"""Show current policy manager status"""
manager = ctx.obj.get('manager')
if not manager:
click.echo("Error: Configuration file required. Use -c/--config option.")
return
status_info = manager.get_policy_status()
print("=== LIGHTNING POLICY MANAGER STATUS ===")
print(f"Session ID: {status_info['session_id']}")
print(f"Total Policy Rules: {status_info['total_rules']}")
print(f"Active Rules: {status_info['active_rules']}")
print(f"Channels with Recent Changes: {status_info['channels_with_changes']}")
print(f"Rollback Candidates: {status_info['rollback_candidates']}")
print(f"Recent Changes (24h): {status_info['recent_changes']}")
# Show policy performance
perf_report = status_info['performance_report']
if perf_report.get('policy_performance'):
print(f"\n=== ACTIVE POLICY PERFORMANCE ===")
perf_table = []
for policy in perf_report['policy_performance']:
last_applied = policy.get('last_applied', 'Never')
if last_applied != 'Never':
last_applied = datetime.fromisoformat(last_applied).strftime('%m/%d %H:%M')
perf_table.append([
policy['name'],
policy['applied_count'],
policy['strategy'],
f"{policy['avg_revenue_impact']:.0f}",
last_applied
])
print(tabulate(perf_table,
headers=["Policy", "Applied", "Strategy", "Avg Revenue", "Last Applied"],
tablefmt="grid"))
@cli.command()
@click.option('--execute', is_flag=True, help='Execute rollbacks (default is dry-run)')
@click.option('--macaroon-path', default=os.getenv('LND_MACAROON_PATH', '/root/.lnd/data/chain/bitcoin/mainnet/admin.macaroon'), help='Path to admin.macaroon file')
@click.option('--cert-path', default=os.getenv('LND_CERT_PATH', '/root/.lnd/tls.cert'), help='Path to tls.cert file')
@click.pass_context
def rollback(ctx, execute, macaroon_path, cert_path):
"""Check for and execute automatic rollbacks of underperforming changes"""
manager = ctx.obj['manager']
async def _rollback():
print("Checking rollback conditions...")
rollback_info = await manager.check_rollback_conditions()
print(f"Found {rollback_info['rollback_candidates']} channels requiring rollback")
if rollback_info['rollback_candidates'] == 0:
print("✓ No rollbacks needed")
return
# Show rollback candidates
print(f"\n=== ROLLBACK CANDIDATES ===")
rollback_table = []
for action in rollback_info['actions']:
rollback_table.append([
action['channel_id'][:16] + "...",
f"{action['revenue_decline']:.1%}",
f"{action['threshold']:.1%}",
f"{action['old_outbound']}{action['new_outbound']}",
f"{action['old_inbound']}{action['new_inbound']}",
', '.join(action['policies'])
])
print(tabulate(rollback_table,
headers=["Channel", "Decline", "Threshold", "Outbound Change", "Inbound Change", "Policies"],
tablefmt="grid"))
if execute:
print(f"\nExecuting {len(rollback_info['actions'])} rollbacks...")
# Initialize LND connection
from src.experiment.lnd_integration import LNDRestClient
async with LNDRestClient(
lnd_rest_url=manager.lnd_rest_url,
cert_path=cert_path,
macaroon_path=macaroon_path
) as lnd_rest:
rollback_results = await manager.execute_rollbacks(
rollback_info['actions'],
lnd_rest
)
print(f"✓ Rollbacks completed:")
print(f" Attempted: {rollback_results['rollbacks_attempted']}")
print(f" Successful: {rollback_results['rollbacks_successful']}")
print(f" Errors: {len(rollback_results['errors'])}")
if rollback_results['errors']:
print(f"\n=== ROLLBACK ERRORS ===")
for error in rollback_results['errors']:
print(f"{error}")
else:
print(f"\nDRY-RUN: Use --execute to actually perform rollbacks")
asyncio.run(_rollback())
@cli.command()
@click.option('--output', '-o', help='Output file for report')
@click.option('--format', 'output_format', default='table',
type=click.Choice(['table', 'json', 'csv']), help='Output format')
@click.pass_context
def report(ctx, output, output_format):
"""Generate comprehensive policy performance report"""
manager = ctx.obj['manager']
status_info = manager.get_policy_status()
perf_report = status_info['performance_report']
if output_format == 'json':
report_data = {
'timestamp': datetime.utcnow().isoformat(),
'session_info': {
'session_id': status_info['session_id'],
'total_rules': status_info['total_rules'],
'active_rules': status_info['active_rules'],
'channels_with_changes': status_info['channels_with_changes']
},
'policy_performance': perf_report['policy_performance']
}
if output:
with open(output, 'w') as f:
json.dump(report_data, f, indent=2)
print(f"✓ JSON report saved to {output}")
else:
print(json.dumps(report_data, indent=2))
elif output_format == 'table':
print("=== POLICY PERFORMANCE REPORT ===")
print(f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Session: {status_info['session_id']}")
print(f"Active Policies: {status_info['active_rules']}/{status_info['total_rules']}")
if perf_report.get('policy_performance'):
print(f"\n=== DETAILED POLICY PERFORMANCE ===")
detailed_table = []
for policy in perf_report['policy_performance']:
last_applied = policy.get('last_applied', 'Never')
if last_applied != 'Never':
last_applied = datetime.fromisoformat(last_applied).strftime('%Y-%m-%d %H:%M')
detailed_table.append([
policy['name'],
policy['strategy'],
policy['applied_count'],
f"{policy['avg_revenue_impact']:+.0f} msat",
last_applied
])
print(tabulate(detailed_table,
headers=["Policy Name", "Strategy", "Times Applied", "Avg Revenue Impact", "Last Applied"],
tablefmt="grid"))
if output:
# Save table format to file
with open(output, 'w') as f:
f.write("Policy Performance Report\n")
f.write(f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
f.write(tabulate(detailed_table,
headers=["Policy Name", "Strategy", "Times Applied", "Avg Revenue Impact", "Last Applied"],
tablefmt="grid"))
print(f"✓ Report saved to {output}")
@cli.command()
@click.argument('output_file', type=click.Path())
@click.pass_context
def generate_config(ctx, output_file):
"""Generate a sample configuration file with advanced features"""
sample_config = create_sample_config()
with open(output_file, 'w') as f:
f.write(sample_config)
print(f"✓ Sample configuration generated: {output_file}")
print()
print("This configuration demonstrates:")
print("• Advanced inbound fee strategies")
print("• Balance-based and flow-based optimization")
print("• Automatic rollback protection")
print("• Revenue maximization policies")
print("• Competitive fee adjustment")
print("• Learning-enabled policies")
print()
print("Edit the configuration to match your node's requirements, then use:")
print(f" ./lightning_policy.py -c {output_file} apply --dry-run")
@cli.command()
@click.option('--watch', is_flag=True, help='Watch mode - apply policies every 10 minutes')
@click.option('--interval', default=10, help='Minutes between policy applications in watch mode')
@click.option('--macaroon-path', default=os.getenv('LND_MACAROON_PATH', '/root/.lnd/data/chain/bitcoin/mainnet/admin.macaroon'), help='Path to admin.macaroon file')
@click.option('--cert-path', default=os.getenv('LND_CERT_PATH', '/root/.lnd/tls.cert'), help='Path to tls.cert file')
@click.pass_context
def daemon(ctx, watch, interval, macaroon_path, cert_path):
"""Run policy manager in daemon mode with automatic rollbacks"""
manager = ctx.obj['manager']
if not watch:
print("Use --watch to enable daemon mode")
return
async def _daemon():
print(f"🤖 Starting policy daemon (interval: {interval} minutes)")
print("Press Ctrl+C to stop")
cycle_count = 0
try:
while True:
cycle_count += 1
print(f"\n--- Cycle {cycle_count} at {datetime.utcnow().strftime('%H:%M:%S')} ---")
# Apply policies
try:
results = await manager.apply_policies(
dry_run=False,
macaroon_path=macaroon_path,
cert_path=cert_path
)
print(f"Applied {results['fee_changes']} fee changes")
if results['errors']:
print(f"WARNING: {len(results['errors'])} errors occurred")
except Exception as e:
print(f"❌ Policy application failed: {e}")
# Check rollbacks
try:
rollback_info = await manager.check_rollback_conditions()
if rollback_info['rollback_candidates'] > 0:
print(f"🔙 Found {rollback_info['rollback_candidates']} rollback candidates")
from src.experiment.lnd_integration import LNDRestClient
async with LNDRestClient(
lnd_rest_url=manager.lnd_rest_url,
cert_path=cert_path,
macaroon_path=macaroon_path
) as lnd_rest:
rollback_results = await manager.execute_rollbacks(
rollback_info['actions'],
lnd_rest
)
print(f"Executed {rollback_results['rollbacks_successful']} rollbacks")
except Exception as e:
print(f"❌ Rollback check failed: {e}")
# Wait for next cycle
print(f"💤 Sleeping for {interval} minutes...")
await asyncio.sleep(interval * 60)
except KeyboardInterrupt:
print("\n🛑 Daemon stopped by user")
asyncio.run(_daemon())
@cli.command()
@click.argument('channel_id')
@click.option('--verbose', is_flag=True, help='Show detailed policy evaluation')
@click.pass_context
def test_channel(ctx, channel_id, verbose):
"""Test policy matching and fee calculation for a specific channel"""
manager = ctx.obj['manager']
async def _test():
print(f"Testing policy evaluation for channel: {channel_id}")
# Get channel data
from src.api.client import LndManageClient
async with LndManageClient(manager.lnd_manage_url) as lnd_manage:
try:
channel_details = await lnd_manage.get_channel_details(channel_id)
enriched_data = await manager._enrich_channel_data(channel_details, lnd_manage)
print(f"\n=== CHANNEL INFO ===")
print(f"Capacity: {enriched_data['capacity']:,} sats")
print(f"Balance Ratio: {enriched_data['local_balance_ratio']:.2%}")
print(f"Activity Level: {enriched_data['activity_level']}")
print(f"Current Outbound Fee: {enriched_data['current_outbound_fee']} ppm")
print(f"Current Inbound Fee: {enriched_data['current_inbound_fee']} ppm")
print(f"7d Flow: {enriched_data['flow_7d']:,} msat")
# Test policy matching
matching_rules = manager.policy_engine.match_channel(enriched_data)
print(f"\n=== POLICY MATCHES ===")
if not matching_rules:
print("No policies matched this channel")
return
for i, rule in enumerate(matching_rules):
print(f"{i+1}. {rule.name} (priority: {rule.priority})")
print(f" Strategy: {rule.policy.strategy.value}")
print(f" Type: {rule.policy.policy_type.value}")
if verbose:
print(f" Applied {rule.applied_count} times")
if rule.last_applied:
print(f" Last applied: {rule.last_applied.strftime('%Y-%m-%d %H:%M')}")
# Calculate recommended fees
outbound_fee, outbound_base, inbound_fee, inbound_base = \
manager.policy_engine.calculate_fees(enriched_data)
print(f"\n=== RECOMMENDED FEES ===")
print(f"Outbound Fee: {outbound_fee} ppm (base: {outbound_base} msat)")
print(f"Inbound Fee: {inbound_fee:+} ppm (base: {inbound_base:+} msat)")
# Show changes
current_out = enriched_data['current_outbound_fee']
current_in = enriched_data['current_inbound_fee']
if outbound_fee != current_out or inbound_fee != current_in:
print(f"\n=== CHANGES ===")
if outbound_fee != current_out:
print(f"Outbound: {current_out}{outbound_fee} ppm ({outbound_fee - current_out:+} ppm)")
if inbound_fee != current_in:
print(f"Inbound: {current_in:+}{inbound_fee:+} ppm ({inbound_fee - current_in:+} ppm)")
else:
print(f"\n✓ No fee changes recommended")
except Exception as e:
print(f"❌ Error testing channel: {e}")
asyncio.run(_test())
if __name__ == "__main__":
cli()