aboutsummaryrefslogtreecommitdiffstats
path: root/scripts/payment-monitor.sh
blob: c50aad9e4eb84800645f49dea68934e202c9ee31 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
#!/bin/bash
# Payment failure monitor for coinos-server
# Watches app container logs and compiles failure analytics
# Run: ./scripts/payment-monitor.sh [hours_to_watch]
# Output: periodic reports to stdout + detailed data to /tmp/payment-failures.jsonl

WATCH_HOURS=${1:-24}
DATA_FILE="/tmp/payment-failures.jsonl"
REPORT_INTERVAL=1800  # report every 30 min
NODE_ID=$(docker exec cl lightning-cli getinfo 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "unknown")

echo "Payment failure monitor started (watching for ${WATCH_HOURS}h)"
echo "Node: ${NODE_ID}"
echo "Data file: ${DATA_FILE}"
echo "Reports every ${REPORT_INTERVAL}s"
echo "---"

# Clear old data
> "$DATA_FILE"

docker logs app -f 2>&1 | timeout $((WATCH_HOURS * 3600)) python3 -u -c "
import sys, json, time, re
from collections import Counter, defaultdict
from datetime import datetime

data_file = '${DATA_FILE}'
report_interval = ${REPORT_INTERVAL}
last_report = time.time()

# Tracking
failures = []
error_types = Counter()
failed_destinations = Counter()
failed_channels = Counter()  # channels that return temporary_channel_failure
fee_insufficient_channels = Counter()
failed_amounts = []  # (amount, error_type)
successful = 0
total_attempts = 0

prev_line = None

def parse_failure(line, error_line):
    global successful, total_attempts
    total_attempts += 1

    try:
        msg = json.loads(line)['msg']
        err_msg = json.loads(error_line)['msg'] if error_line else ''
    except:
        return

    # Parse: username payment failed amount balance hash
    m = re.match(r'(\S+) payment failed (\d+) (\d+)\s+(.*)', msg)
    if not m:
        return

    username, amount, balance, rest = m.group(1), int(m.group(2)), int(m.group(3)), m.group(4)

    # Categorize error
    if 'Insufficient funds' in err_msg or 'insuficientes' in err_msg.lower():
        etype = 'insufficient_funds'
    elif 'excessive cost' in err_msg:
        etype = 'excessive_cost'
    elif 'temporary_channel_failure' in err_msg:
        etype = 'temporary_channel_failure'
        # Extract failed channel IDs
        for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg):
            failed_channels[ch] += 1
    elif 'fee_insufficient' in err_msg:
        etype = 'fee_insufficient'
        for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg):
            fee_insufficient_channels[ch] += 1
    elif 'Timed out' in err_msg:
        etype = 'timed_out'
        for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg):
            if 'temporary_channel_failure' in err_msg:
                failed_channels[ch] += 1
            if 'fee_insufficient' in err_msg:
                fee_insufficient_channels[ch] += 1
    elif 'Cannot send to self' in err_msg:
        etype = 'self_pay'
    elif 'incorrect_or_unknown' in err_msg:
        etype = 'unknown_invoice'
    elif 'already underway' in err_msg:
        etype = 'already_underway'
    elif 'already been paid' in err_msg:
        etype = 'already_paid'
    elif 'user not provided' in err_msg:
        etype = 'user_not_found'
    elif err_msg:
        etype = err_msg[:80]
    else:
        etype = 'unknown'

    error_types[etype] += 1
    failed_amounts.append((amount, etype))

    # Try to get destination from invoice
    if rest and rest.startswith('ln'):
        bolt11_prefix = rest[:20]
        failed_destinations[bolt11_prefix] += 1

    record = {
        'time': datetime.utcnow().isoformat(),
        'user': username,
        'amount': amount,
        'balance': balance,
        'error': etype,
        'detail': err_msg[:200] if err_msg else '',
    }

    with open(data_file, 'a') as f:
        f.write(json.dumps(record) + '\n')

def parse_success(line):
    global successful
    successful += 1

def report():
    global last_report
    now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')

    print(f'\n====== PAYMENT FAILURE REPORT {now} UTC ======')
    print(f'Total attempts: {total_attempts}  Successful: {successful}  Failed: {total_attempts}')
    if total_attempts > 0:
        print(f'Success rate: {successful/(successful+total_attempts)*100:.1f}%')

    print(f'\n--- Error breakdown ---')
    for etype, count in error_types.most_common(15):
        print(f'  {count:>5}  {etype}')

    if failed_channels:
        print(f'\n--- Channels with liquidity failures (top 10) ---')
        for ch, count in failed_channels.most_common(10):
            print(f'  {count:>5}  {ch}')

    if fee_insufficient_channels:
        print(f'\n--- Channels with stale fee gossip (top 10) ---')
        for ch, count in fee_insufficient_channels.most_common(10):
            print(f'  {count:>5}  {ch}')

    # Amount distribution of failures
    if failed_amounts:
        amounts_by_type = defaultdict(list)
        for amt, etype in failed_amounts:
            amounts_by_type[etype].append(amt)

        print(f'\n--- Failed amount ranges by error ---')
        for etype in ['insufficient_funds', 'excessive_cost', 'temporary_channel_failure', 'timed_out', 'fee_insufficient']:
            amts = amounts_by_type.get(etype, [])
            if amts:
                print(f'  {etype}: min={min(amts)} max={max(amts)} avg={sum(amts)//len(amts)} count={len(amts)}')

    print(f'\n--- Recommendations ---')
    if error_types.get('insufficient_funds', 0) > 10:
        print('  * HIGH insufficient_funds: Consider showing fee preview before payment attempt')
    if error_types.get('excessive_cost', 0) > 5:
        print('  * HIGH excessive_cost: Increase default maxfee or improve channel connectivity')
    if error_types.get('fee_insufficient', 0) > 5:
        print('  * STALE GOSSIP: Some channels advertise lower fees than they charge')
        print('    Nodes to investigate:')
        for ch, count in fee_insufficient_channels.most_common(5):
            scid = ch.split('/')[0]
            print(f'      {scid} ({count} failures)')
    if failed_channels:
        print('  * LIQUIDITY BOTTLENECKS:')
        for ch, count in failed_channels.most_common(5):
            if count >= 3:
                scid = ch.split('/')[0]
                print(f'      {scid} ({count} failures) - consider opening channel to bypass')
    if error_types.get('self_pay', 0) > 5:
        print('  * SELF-PAY: Users trying to pay own invoices - consider UI guard')

    print('=' * 55)
    sys.stdout.flush()
    last_report = time.time()

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue

    try:
        d = json.loads(line)
        msg = d.get('msg', '')
    except:
        prev_line = line
        continue

    # Track successes
    if 'sent lightning' in msg or 'sent liquid' in msg or 'sent bitcoin' in msg:
        parse_success(line)

    # Track failures - need the next line for error detail
    if prev_line and 'payment failed' in prev_line:
        try:
            prev_d = json.loads(prev_line)
            if 'payment failed' in prev_d.get('msg', ''):
                parse_failure(prev_line, line)
        except:
            pass

    prev_line = line

    # Periodic report
    if time.time() - last_report >= report_interval:
        report()
" 2>&1

echo "Monitor finished after ${WATCH_HOURS}h"