csm-dashboard 0.2.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.1.dist-info}/METADATA +90 -25
- csm_dashboard-0.3.1.dist-info/RECORD +32 -0
- src/abis/CSAccounting.json +22 -0
- src/abis/stETH.json +10 -0
- src/cli/commands.py +266 -45
- src/core/config.py +3 -0
- src/core/types.py +77 -5
- src/data/etherscan.py +60 -0
- src/data/ipfs_logs.py +42 -2
- src/data/lido_api.py +105 -0
- src/data/onchain.py +191 -0
- src/data/strikes.py +40 -7
- src/services/operator_service.py +352 -34
- src/web/app.py +10 -7
- src/web/routes.py +77 -11
- csm_dashboard-0.2.2.dist-info/RECORD +0 -32
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.1.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.1.dist-info}/entry_points.txt +0 -0
src/cli/commands.py
CHANGED
|
@@ -25,23 +25,25 @@ def run_async(coro):
|
|
|
25
25
|
return asyncio.run(coro)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def format_as_api_json(rewards: OperatorRewards, include_validators: bool = False) -> dict:
|
|
28
|
+
def format_as_api_json(rewards: OperatorRewards, include_validators: bool = False, include_withdrawals: bool = False) -> dict:
|
|
29
29
|
"""Format rewards data in the same structure as the API endpoint."""
|
|
30
30
|
result = {
|
|
31
31
|
"operator_id": rewards.node_operator_id,
|
|
32
32
|
"manager_address": rewards.manager_address,
|
|
33
33
|
"reward_address": rewards.reward_address,
|
|
34
|
+
"curve_id": rewards.curve_id,
|
|
35
|
+
"operator_type": rewards.operator_type,
|
|
34
36
|
"rewards": {
|
|
35
|
-
"current_bond_eth":
|
|
36
|
-
"required_bond_eth":
|
|
37
|
-
"excess_bond_eth":
|
|
37
|
+
"current_bond_eth": str(rewards.current_bond_eth),
|
|
38
|
+
"required_bond_eth": str(rewards.required_bond_eth),
|
|
39
|
+
"excess_bond_eth": str(rewards.excess_bond_eth),
|
|
38
40
|
"cumulative_rewards_shares": rewards.cumulative_rewards_shares,
|
|
39
|
-
"cumulative_rewards_eth":
|
|
41
|
+
"cumulative_rewards_eth": str(rewards.cumulative_rewards_eth),
|
|
40
42
|
"distributed_shares": rewards.distributed_shares,
|
|
41
|
-
"distributed_eth":
|
|
43
|
+
"distributed_eth": str(rewards.distributed_eth),
|
|
42
44
|
"unclaimed_shares": rewards.unclaimed_shares,
|
|
43
|
-
"unclaimed_eth":
|
|
44
|
-
"total_claimable_eth":
|
|
45
|
+
"unclaimed_eth": str(rewards.unclaimed_eth),
|
|
46
|
+
"total_claimable_eth": str(rewards.total_claimable_eth),
|
|
45
47
|
},
|
|
46
48
|
"validators": {
|
|
47
49
|
"total": rewards.total_validators,
|
|
@@ -64,13 +66,53 @@ def format_as_api_json(rewards: OperatorRewards, include_validators: bool = Fals
|
|
|
64
66
|
|
|
65
67
|
# Add APY metrics if available
|
|
66
68
|
if rewards.apy:
|
|
69
|
+
# Use actual excess bond for lifetime values (matches Web API)
|
|
70
|
+
lifetime_bond = float(rewards.excess_bond_eth)
|
|
71
|
+
lifetime_net_total = (rewards.apy.lifetime_distribution_eth or 0) + lifetime_bond
|
|
72
|
+
|
|
67
73
|
result["apy"] = {
|
|
74
|
+
"previous_distribution_eth": rewards.apy.previous_distribution_eth,
|
|
75
|
+
"previous_distribution_apy": rewards.apy.previous_distribution_apy,
|
|
76
|
+
"previous_net_apy": rewards.apy.previous_net_apy,
|
|
77
|
+
"previous_bond_eth": rewards.apy.previous_bond_eth,
|
|
78
|
+
"previous_bond_apr": rewards.apy.previous_bond_apr,
|
|
79
|
+
"previous_net_total_eth": rewards.apy.previous_net_total_eth,
|
|
80
|
+
"current_distribution_eth": rewards.apy.current_distribution_eth,
|
|
81
|
+
"current_distribution_apy": rewards.apy.current_distribution_apy,
|
|
82
|
+
"current_bond_eth": rewards.apy.current_bond_eth,
|
|
83
|
+
"current_bond_apr": rewards.apy.current_bond_apr,
|
|
84
|
+
"current_net_total_eth": rewards.apy.current_net_total_eth,
|
|
85
|
+
"lifetime_distribution_eth": rewards.apy.lifetime_distribution_eth,
|
|
86
|
+
"lifetime_bond_eth": lifetime_bond, # Actual excess bond, not estimate
|
|
87
|
+
"lifetime_net_total_eth": lifetime_net_total, # Matches Total Claimable
|
|
88
|
+
# Accurate lifetime APY (per-frame bond calculation when available)
|
|
89
|
+
"lifetime_reward_apy": rewards.apy.lifetime_reward_apy,
|
|
90
|
+
"lifetime_bond_apy": rewards.apy.lifetime_bond_apy,
|
|
91
|
+
"lifetime_net_apy": rewards.apy.lifetime_net_apy,
|
|
92
|
+
"next_distribution_date": rewards.apy.next_distribution_date,
|
|
93
|
+
"next_distribution_est_eth": rewards.apy.next_distribution_est_eth,
|
|
68
94
|
"historical_reward_apy_28d": rewards.apy.historical_reward_apy_28d,
|
|
69
95
|
"historical_reward_apy_ltd": rewards.apy.historical_reward_apy_ltd,
|
|
70
96
|
"bond_apy": rewards.apy.bond_apy,
|
|
71
97
|
"net_apy_28d": rewards.apy.net_apy_28d,
|
|
72
98
|
"net_apy_ltd": rewards.apy.net_apy_ltd,
|
|
99
|
+
"uses_historical_apr": rewards.apy.uses_historical_apr,
|
|
73
100
|
}
|
|
101
|
+
# Add frames if available (from --history flag)
|
|
102
|
+
if rewards.apy.frames:
|
|
103
|
+
result["apy"]["frames"] = [
|
|
104
|
+
{
|
|
105
|
+
"frame_number": f.frame_number,
|
|
106
|
+
"start_date": f.start_date,
|
|
107
|
+
"end_date": f.end_date,
|
|
108
|
+
"rewards_eth": f.rewards_eth,
|
|
109
|
+
"rewards_shares": f.rewards_shares,
|
|
110
|
+
"duration_days": f.duration_days,
|
|
111
|
+
"validator_count": f.validator_count,
|
|
112
|
+
"apy": f.apy,
|
|
113
|
+
}
|
|
114
|
+
for f in rewards.apy.frames
|
|
115
|
+
]
|
|
74
116
|
|
|
75
117
|
# Add active_since if available
|
|
76
118
|
if rewards.active_since:
|
|
@@ -80,7 +122,7 @@ def format_as_api_json(rewards: OperatorRewards, include_validators: bool = Fals
|
|
|
80
122
|
if rewards.health:
|
|
81
123
|
result["health"] = {
|
|
82
124
|
"bond_healthy": rewards.health.bond_healthy,
|
|
83
|
-
"bond_deficit_eth":
|
|
125
|
+
"bond_deficit_eth": str(rewards.health.bond_deficit_eth),
|
|
84
126
|
"stuck_validators_count": rewards.health.stuck_validators_count,
|
|
85
127
|
"slashed_validators_count": rewards.health.slashed_validators_count,
|
|
86
128
|
"validators_at_risk_count": rewards.health.validators_at_risk_count,
|
|
@@ -94,6 +136,19 @@ def format_as_api_json(rewards: OperatorRewards, include_validators: bool = Fals
|
|
|
94
136
|
"has_issues": rewards.health.has_issues,
|
|
95
137
|
}
|
|
96
138
|
|
|
139
|
+
# Add withdrawal history if requested
|
|
140
|
+
if include_withdrawals and rewards.withdrawals:
|
|
141
|
+
result["withdrawals"] = [
|
|
142
|
+
{
|
|
143
|
+
"block_number": w.block_number,
|
|
144
|
+
"timestamp": w.timestamp,
|
|
145
|
+
"shares": w.shares,
|
|
146
|
+
"eth_value": w.eth_value,
|
|
147
|
+
"tx_hash": w.tx_hash,
|
|
148
|
+
}
|
|
149
|
+
for w in rewards.withdrawals
|
|
150
|
+
]
|
|
151
|
+
|
|
97
152
|
return result
|
|
98
153
|
|
|
99
154
|
|
|
@@ -115,6 +170,12 @@ def rewards(
|
|
|
115
170
|
detailed: bool = typer.Option(
|
|
116
171
|
False, "--detailed", "-d", help="Include validator status from beacon chain"
|
|
117
172
|
),
|
|
173
|
+
history: bool = typer.Option(
|
|
174
|
+
False, "--history", "-H", help="Show all historical distribution frames"
|
|
175
|
+
),
|
|
176
|
+
withdrawals: bool = typer.Option(
|
|
177
|
+
False, "--withdrawals", "-w", help="Include withdrawal/claim history"
|
|
178
|
+
),
|
|
118
179
|
):
|
|
119
180
|
"""
|
|
120
181
|
Check CSM operator rewards and earnings.
|
|
@@ -124,6 +185,8 @@ def rewards(
|
|
|
124
185
|
csm rewards 42
|
|
125
186
|
csm rewards 0xYourAddress --json
|
|
126
187
|
csm rewards 42 --detailed
|
|
188
|
+
csm rewards 42 --history
|
|
189
|
+
csm rewards 42 --withdrawals
|
|
127
190
|
"""
|
|
128
191
|
if address is None and operator_id is None:
|
|
129
192
|
console.print("[red]Error: Must provide either ADDRESS or --id[/red]")
|
|
@@ -139,20 +202,20 @@ def rewards(
|
|
|
139
202
|
if not output_json:
|
|
140
203
|
console.print()
|
|
141
204
|
status_msg = "[bold blue]Fetching operator data..."
|
|
142
|
-
if detailed:
|
|
205
|
+
if detailed or history or withdrawals:
|
|
143
206
|
status_msg = "[bold blue]Fetching operator data and validator status..."
|
|
144
207
|
with console.status(status_msg):
|
|
145
208
|
if operator_id is not None:
|
|
146
|
-
rewards = run_async(service.get_operator_by_id(operator_id, detailed))
|
|
209
|
+
rewards = run_async(service.get_operator_by_id(operator_id, detailed or history, history, withdrawals))
|
|
147
210
|
else:
|
|
148
211
|
console.print(f"[dim]Looking up operator for address: {address}[/dim]")
|
|
149
|
-
rewards = run_async(service.get_operator_by_address(address, detailed))
|
|
212
|
+
rewards = run_async(service.get_operator_by_address(address, detailed or history, history, withdrawals))
|
|
150
213
|
else:
|
|
151
214
|
# JSON mode - no status output
|
|
152
215
|
if operator_id is not None:
|
|
153
|
-
rewards = run_async(service.get_operator_by_id(operator_id, detailed))
|
|
216
|
+
rewards = run_async(service.get_operator_by_id(operator_id, detailed or history, history, withdrawals))
|
|
154
217
|
else:
|
|
155
|
-
rewards = run_async(service.get_operator_by_address(address, detailed))
|
|
218
|
+
rewards = run_async(service.get_operator_by_address(address, detailed or history, history, withdrawals))
|
|
156
219
|
|
|
157
220
|
if rewards is None:
|
|
158
221
|
if output_json:
|
|
@@ -163,17 +226,18 @@ def rewards(
|
|
|
163
226
|
|
|
164
227
|
# JSON output mode
|
|
165
228
|
if output_json:
|
|
166
|
-
print(json.dumps(format_as_api_json(rewards, detailed), indent=2))
|
|
229
|
+
print(json.dumps(format_as_api_json(rewards, detailed, withdrawals), indent=2))
|
|
167
230
|
return
|
|
168
231
|
|
|
169
232
|
# Header panel
|
|
170
233
|
active_since_str = ""
|
|
171
234
|
if rewards.active_since:
|
|
172
235
|
active_since_str = f"Active Since: {rewards.active_since.strftime('%b %d, %Y')}"
|
|
236
|
+
operator_type_str = f"Type: {rewards.operator_type}"
|
|
173
237
|
console.print(
|
|
174
238
|
Panel(
|
|
175
239
|
f"[bold]CSM Operator #{rewards.node_operator_id}[/bold]\n"
|
|
176
|
-
f"{active_since_str}\n\n"
|
|
240
|
+
f"{active_since_str} | {operator_type_str}\n\n"
|
|
177
241
|
f"Manager: {rewards.manager_address}\n"
|
|
178
242
|
f"Rewards: {rewards.reward_address}",
|
|
179
243
|
title="Operator Info",
|
|
@@ -352,35 +416,190 @@ def rewards(
|
|
|
352
416
|
console.print(rewards_table)
|
|
353
417
|
console.print()
|
|
354
418
|
|
|
355
|
-
# APY Metrics table (only shown with --detailed flag)
|
|
356
|
-
if detailed and rewards.apy:
|
|
357
|
-
apy_table = Table(title="APY Metrics (Historical)")
|
|
358
|
-
apy_table.add_column("Metric", style="cyan")
|
|
359
|
-
apy_table.add_column("28-Day", style="green", justify="right")
|
|
360
|
-
apy_table.add_column("Lifetime", style="green", justify="right")
|
|
361
|
-
|
|
419
|
+
# APY Metrics table (only shown with --detailed or --history flag)
|
|
420
|
+
if (detailed or history) and rewards.apy:
|
|
362
421
|
def fmt_apy(val: float | None) -> str:
|
|
363
422
|
return f"{val:.2f}%" if val is not None else "--"
|
|
364
423
|
|
|
365
|
-
|
|
366
|
-
"
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
424
|
+
def fmt_eth(val: float | None) -> str:
|
|
425
|
+
return f"{val:.4f}" if val is not None else "--"
|
|
426
|
+
|
|
427
|
+
# Determine which columns to show
|
|
428
|
+
# --detailed only: Current column only
|
|
429
|
+
# --history: Previous, Current, and Lifetime columns
|
|
430
|
+
show_all_columns = history
|
|
431
|
+
|
|
432
|
+
if show_all_columns:
|
|
433
|
+
# Full table with 3 columns (Previous, Current, Lifetime)
|
|
434
|
+
apy_table = Table(title="APY Metrics")
|
|
435
|
+
apy_table.add_column("Metric", style="cyan")
|
|
436
|
+
apy_table.add_column("Previous", style="green", justify="right")
|
|
437
|
+
apy_table.add_column("Current", style="green", justify="right")
|
|
438
|
+
apy_table.add_column("Lifetime", style="green", justify="right")
|
|
439
|
+
|
|
440
|
+
# Use accurate lifetime APY when available (per-frame bond calculation)
|
|
441
|
+
lifetime_reward_apy = rewards.apy.lifetime_reward_apy or rewards.apy.historical_reward_apy_ltd
|
|
442
|
+
lifetime_bond_apy = rewards.apy.lifetime_bond_apy or rewards.apy.bond_apy
|
|
443
|
+
lifetime_net_apy = rewards.apy.lifetime_net_apy or rewards.apy.net_apy_ltd
|
|
444
|
+
|
|
445
|
+
apy_table.add_row(
|
|
446
|
+
"Reward APY",
|
|
447
|
+
fmt_apy(rewards.apy.previous_distribution_apy),
|
|
448
|
+
fmt_apy(rewards.apy.current_distribution_apy),
|
|
449
|
+
fmt_apy(lifetime_reward_apy),
|
|
450
|
+
)
|
|
451
|
+
# Show historical APR for Previous/Current if available, otherwise current APR
|
|
452
|
+
prev_bond_apr = rewards.apy.previous_bond_apr or rewards.apy.bond_apy
|
|
453
|
+
curr_bond_apr = rewards.apy.current_bond_apr or rewards.apy.bond_apy
|
|
454
|
+
bond_label = "Bond APY (stETH)"
|
|
455
|
+
apy_table.add_row(
|
|
456
|
+
bond_label,
|
|
457
|
+
fmt_apy(prev_bond_apr),
|
|
458
|
+
fmt_apy(curr_bond_apr),
|
|
459
|
+
fmt_apy(lifetime_bond_apy),
|
|
460
|
+
)
|
|
461
|
+
apy_table.add_row(
|
|
462
|
+
"[bold]NET APY[/bold]",
|
|
463
|
+
f"[bold yellow]{fmt_apy(rewards.apy.previous_net_apy)}[/bold yellow]",
|
|
464
|
+
f"[bold yellow]{fmt_apy(rewards.apy.net_apy_28d)}[/bold yellow]",
|
|
465
|
+
f"[bold yellow]{fmt_apy(lifetime_net_apy)}[/bold yellow]",
|
|
466
|
+
)
|
|
467
|
+
apy_table.add_row("─" * 15, "─" * 10, "─" * 10, "─" * 10)
|
|
468
|
+
apy_table.add_row(
|
|
469
|
+
"Rewards (stETH)",
|
|
470
|
+
fmt_eth(rewards.apy.previous_distribution_eth),
|
|
471
|
+
fmt_eth(rewards.apy.current_distribution_eth),
|
|
472
|
+
fmt_eth(rewards.apy.lifetime_distribution_eth),
|
|
473
|
+
)
|
|
474
|
+
# All columns show estimated bond stETH rebasing earnings (consistent metric)
|
|
475
|
+
apy_table.add_row(
|
|
476
|
+
"Bond (stETH)*",
|
|
477
|
+
fmt_eth(rewards.apy.previous_bond_eth),
|
|
478
|
+
fmt_eth(rewards.apy.current_bond_eth),
|
|
479
|
+
fmt_eth(rewards.apy.lifetime_bond_eth),
|
|
480
|
+
)
|
|
481
|
+
# All columns show sum of Rewards + Bond (consistent metric)
|
|
482
|
+
apy_table.add_row(
|
|
483
|
+
"[bold]Net Total (stETH)[/bold]",
|
|
484
|
+
f"[bold]{fmt_eth(rewards.apy.previous_net_total_eth)}[/bold]",
|
|
485
|
+
f"[bold]{fmt_eth(rewards.apy.current_net_total_eth)}[/bold]",
|
|
486
|
+
f"[bold]{fmt_eth(rewards.apy.lifetime_net_total_eth)}[/bold]",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
console.print(apy_table)
|
|
490
|
+
# Show footnote about per-frame bond calculation
|
|
491
|
+
console.print("[dim]*Previous/Current use per-frame validator count for bond calculations[/dim]")
|
|
492
|
+
else:
|
|
493
|
+
# Single column (Current only) for --detailed without --history
|
|
494
|
+
apy_table = Table(title="APY Metrics (Current Frame)")
|
|
495
|
+
apy_table.add_column("Metric", style="cyan")
|
|
496
|
+
apy_table.add_column("Current", style="green", justify="right")
|
|
497
|
+
|
|
498
|
+
apy_table.add_row(
|
|
499
|
+
"Reward APY",
|
|
500
|
+
fmt_apy(rewards.apy.current_distribution_apy),
|
|
501
|
+
)
|
|
502
|
+
curr_bond_apr = rewards.apy.current_bond_apr or rewards.apy.bond_apy
|
|
503
|
+
apy_table.add_row(
|
|
504
|
+
"Bond APY (stETH)",
|
|
505
|
+
fmt_apy(curr_bond_apr),
|
|
506
|
+
)
|
|
507
|
+
apy_table.add_row(
|
|
508
|
+
"[bold]NET APY[/bold]",
|
|
509
|
+
f"[bold yellow]{fmt_apy(rewards.apy.net_apy_28d)}[/bold yellow]",
|
|
510
|
+
)
|
|
511
|
+
apy_table.add_row("─" * 15, "─" * 10)
|
|
512
|
+
apy_table.add_row(
|
|
513
|
+
"Rewards (stETH)",
|
|
514
|
+
fmt_eth(rewards.apy.current_distribution_eth),
|
|
515
|
+
)
|
|
516
|
+
apy_table.add_row(
|
|
517
|
+
"Bond (stETH)*",
|
|
518
|
+
fmt_eth(rewards.apy.current_bond_eth),
|
|
519
|
+
)
|
|
520
|
+
apy_table.add_row(
|
|
521
|
+
"[bold]Net Total (stETH)[/bold]",
|
|
522
|
+
f"[bold]{fmt_eth(rewards.apy.current_net_total_eth)}[/bold]",
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
console.print(apy_table)
|
|
526
|
+
# Show appropriate footer based on whether historical APR was used
|
|
527
|
+
if rewards.apy.uses_historical_apr:
|
|
528
|
+
console.print("[dim]*Bond (stETH) is estimated from current bond and historical APR[/dim]")
|
|
529
|
+
else:
|
|
530
|
+
console.print("[dim]*Bond (stETH) is estimated from current bond and APR[/dim]")
|
|
531
|
+
|
|
532
|
+
# Show next distribution estimate
|
|
533
|
+
if rewards.apy.next_distribution_date:
|
|
534
|
+
from datetime import datetime
|
|
535
|
+
try:
|
|
536
|
+
next_dt = datetime.fromisoformat(rewards.apy.next_distribution_date)
|
|
537
|
+
next_date_str = next_dt.strftime("%b %d, %Y")
|
|
538
|
+
est_eth = rewards.apy.next_distribution_est_eth
|
|
539
|
+
if est_eth:
|
|
540
|
+
console.print(f"\n[cyan]Next Distribution:[/cyan] ~{next_date_str} (est. {est_eth:.4f} ETH)")
|
|
541
|
+
else:
|
|
542
|
+
console.print(f"\n[cyan]Next Distribution:[/cyan] ~{next_date_str}")
|
|
543
|
+
except (ValueError, TypeError):
|
|
544
|
+
pass
|
|
545
|
+
console.print()
|
|
546
|
+
|
|
547
|
+
# Show full distribution history if --history flag is used
|
|
548
|
+
if history and rewards.apy.frames:
|
|
549
|
+
from datetime import datetime
|
|
550
|
+
history_table = Table(title="Distribution History")
|
|
551
|
+
history_table.add_column("#", style="cyan", justify="right")
|
|
552
|
+
history_table.add_column("Distribution Date", style="white")
|
|
553
|
+
history_table.add_column("Rewards (ETH)", style="green", justify="right")
|
|
554
|
+
history_table.add_column("Vals", style="dim", justify="right")
|
|
555
|
+
history_table.add_column("ETH/Val", style="green", justify="right")
|
|
556
|
+
|
|
557
|
+
# Display oldest first (chronological order)
|
|
558
|
+
for frame in rewards.apy.frames:
|
|
559
|
+
try:
|
|
560
|
+
end_dt = datetime.fromisoformat(frame.end_date)
|
|
561
|
+
dist_date = end_dt.strftime("%b %d, %Y")
|
|
562
|
+
except (ValueError, TypeError):
|
|
563
|
+
dist_date = frame.end_date
|
|
564
|
+
|
|
565
|
+
# Calculate ETH per validator
|
|
566
|
+
eth_per_val = frame.rewards_eth / frame.validator_count if frame.validator_count > 0 else 0
|
|
567
|
+
|
|
568
|
+
history_table.add_row(
|
|
569
|
+
str(frame.frame_number),
|
|
570
|
+
dist_date,
|
|
571
|
+
f"{frame.rewards_eth:.4f}",
|
|
572
|
+
str(frame.validator_count),
|
|
573
|
+
f"{eth_per_val:.6f}",
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
console.print(history_table)
|
|
577
|
+
console.print()
|
|
578
|
+
|
|
579
|
+
# Show withdrawal history if --withdrawals flag is used
|
|
580
|
+
if withdrawals and rewards.withdrawals:
|
|
581
|
+
from datetime import datetime
|
|
582
|
+
withdrawal_table = Table(title="Withdrawal History")
|
|
583
|
+
withdrawal_table.add_column("#", style="cyan", justify="right")
|
|
584
|
+
withdrawal_table.add_column("Date", style="white")
|
|
585
|
+
withdrawal_table.add_column("Amount (stETH)", style="green", justify="right")
|
|
586
|
+
withdrawal_table.add_column("Tx Hash", style="dim")
|
|
587
|
+
|
|
588
|
+
for i, w in enumerate(rewards.withdrawals, 1):
|
|
589
|
+
try:
|
|
590
|
+
w_dt = datetime.fromisoformat(w.timestamp)
|
|
591
|
+
w_date = w_dt.strftime("%b %d, %Y")
|
|
592
|
+
except (ValueError, TypeError):
|
|
593
|
+
w_date = w.timestamp[:10] if w.timestamp else "--"
|
|
594
|
+
|
|
595
|
+
withdrawal_table.add_row(
|
|
596
|
+
str(i),
|
|
597
|
+
w_date,
|
|
598
|
+
f"{w.eth_value:.4f}",
|
|
599
|
+
f"{w.tx_hash[:10]}..." if w.tx_hash else "--",
|
|
600
|
+
)
|
|
381
601
|
|
|
382
|
-
console.print(
|
|
383
|
-
console.print("[dim]*Bond APY uses current stETH rate[/dim]")
|
|
602
|
+
console.print(withdrawal_table)
|
|
384
603
|
console.print()
|
|
385
604
|
|
|
386
605
|
|
|
@@ -446,11 +665,12 @@ def health(
|
|
|
446
665
|
# Fetch validator strikes details
|
|
447
666
|
validator_strikes = []
|
|
448
667
|
if rewards.health.strikes.total_validators_with_strikes > 0:
|
|
449
|
-
strikes_data = run_async(service.get_operator_strikes(rewards.node_operator_id))
|
|
668
|
+
strikes_data = run_async(service.get_operator_strikes(rewards.node_operator_id, rewards.curve_id))
|
|
450
669
|
validator_strikes = [
|
|
451
670
|
{
|
|
452
671
|
"pubkey": vs.pubkey,
|
|
453
672
|
"strike_count": vs.strike_count,
|
|
673
|
+
"strike_threshold": vs.strike_threshold,
|
|
454
674
|
"at_ejection_risk": vs.at_ejection_risk,
|
|
455
675
|
}
|
|
456
676
|
for vs in strikes_data
|
|
@@ -458,7 +678,7 @@ def health(
|
|
|
458
678
|
|
|
459
679
|
result["health"] = {
|
|
460
680
|
"bond_healthy": rewards.health.bond_healthy,
|
|
461
|
-
"bond_deficit_eth":
|
|
681
|
+
"bond_deficit_eth": str(rewards.health.bond_deficit_eth),
|
|
462
682
|
"stuck_validators_count": rewards.health.stuck_validators_count,
|
|
463
683
|
"slashed_validators_count": rewards.health.slashed_validators_count,
|
|
464
684
|
"validators_at_risk_count": rewards.health.validators_at_risk_count,
|
|
@@ -468,6 +688,7 @@ def health(
|
|
|
468
688
|
"validators_near_ejection": rewards.health.strikes.validators_near_ejection,
|
|
469
689
|
"total_strikes": rewards.health.strikes.total_strikes,
|
|
470
690
|
"max_strikes": rewards.health.strikes.max_strikes,
|
|
691
|
+
"strike_threshold": rewards.health.strikes.strike_threshold,
|
|
471
692
|
"validators": validator_strikes,
|
|
472
693
|
},
|
|
473
694
|
"has_issues": rewards.health.has_issues,
|
|
@@ -564,9 +785,9 @@ def health(
|
|
|
564
785
|
if strikes.total_validators_with_strikes > 0:
|
|
565
786
|
console.print()
|
|
566
787
|
console.print("[bold]Validator Strikes Detail:[/bold]")
|
|
567
|
-
validator_strikes = run_async(service.get_operator_strikes(rewards.node_operator_id))
|
|
788
|
+
validator_strikes = run_async(service.get_operator_strikes(rewards.node_operator_id, rewards.curve_id))
|
|
568
789
|
for vs in validator_strikes:
|
|
569
|
-
strike_display = f"{vs.strike_count}/
|
|
790
|
+
strike_display = f"{vs.strike_count}/{vs.strike_threshold}"
|
|
570
791
|
if vs.at_ejection_risk:
|
|
571
792
|
console.print(f" {vs.pubkey}: [red bold]{strike_display}[/red bold] (EJECTION RISK!)")
|
|
572
793
|
elif vs.strike_count > 0:
|
src/core/config.py
CHANGED
|
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
|
|
|
20
20
|
# Etherscan API (optional, for historical event queries)
|
|
21
21
|
etherscan_api_key: str | None = None
|
|
22
22
|
|
|
23
|
+
# The Graph API (optional, for historical stETH APR data)
|
|
24
|
+
thegraph_api_key: str | None = None
|
|
25
|
+
|
|
23
26
|
# Data Sources
|
|
24
27
|
rewards_proofs_url: str = (
|
|
25
28
|
"https://raw.githubusercontent.com/lidofinance/csm-rewards/mainnet/proofs.json"
|
src/core/types.py
CHANGED
|
@@ -48,6 +48,29 @@ class RewardsInfo(BaseModel):
|
|
|
48
48
|
proof: list[str]
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class DistributionFrame(BaseModel):
|
|
52
|
+
"""Single distribution frame data."""
|
|
53
|
+
|
|
54
|
+
frame_number: int
|
|
55
|
+
start_date: str # ISO format
|
|
56
|
+
end_date: str
|
|
57
|
+
rewards_eth: float
|
|
58
|
+
rewards_shares: int
|
|
59
|
+
duration_days: float
|
|
60
|
+
validator_count: int = 0 # Number of validators in this frame
|
|
61
|
+
apy: float | None = None # Annualized for this frame (kept for backwards compat)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WithdrawalEvent(BaseModel):
|
|
65
|
+
"""A single reward claim/withdrawal event."""
|
|
66
|
+
|
|
67
|
+
block_number: int
|
|
68
|
+
timestamp: str # ISO format
|
|
69
|
+
shares: int
|
|
70
|
+
eth_value: float
|
|
71
|
+
tx_hash: str
|
|
72
|
+
|
|
73
|
+
|
|
51
74
|
class APYMetrics(BaseModel):
|
|
52
75
|
"""APY calculations for an operator.
|
|
53
76
|
|
|
@@ -59,17 +82,58 @@ class APYMetrics(BaseModel):
|
|
|
59
82
|
which is more accurate than calculating from unclaimed amounts.
|
|
60
83
|
"""
|
|
61
84
|
|
|
85
|
+
# Previous distribution frame metrics
|
|
86
|
+
previous_distribution_eth: float | None = None
|
|
87
|
+
previous_distribution_apy: float | None = None
|
|
88
|
+
previous_net_apy: float | None = None # previous_reward_apy + previous_bond_apy
|
|
89
|
+
|
|
90
|
+
# Current distribution frame metrics
|
|
91
|
+
current_distribution_eth: float | None = None
|
|
92
|
+
current_distribution_apy: float | None = None
|
|
93
|
+
|
|
94
|
+
# Next distribution estimates
|
|
95
|
+
next_distribution_date: str | None = None # ISO format
|
|
96
|
+
next_distribution_est_eth: float | None = None
|
|
97
|
+
|
|
98
|
+
# Lifetime totals
|
|
99
|
+
lifetime_distribution_eth: float | None = None # Sum of all frame rewards
|
|
100
|
+
|
|
101
|
+
# Accurate lifetime APY (calculated with per-frame bond when history available)
|
|
102
|
+
lifetime_reward_apy: float | None = None # Duration-weighted avg from all frames
|
|
103
|
+
lifetime_bond_apy: float | None = None # Duration-weighted avg bond APY
|
|
104
|
+
lifetime_net_apy: float | None = None # lifetime_reward_apy + lifetime_bond_apy
|
|
105
|
+
|
|
62
106
|
# Historical Reward APY (from IPFS distribution logs) - most accurate
|
|
63
|
-
historical_reward_apy_28d: float | None = None #
|
|
64
|
-
historical_reward_apy_ltd: float | None = None # Lifetime
|
|
107
|
+
historical_reward_apy_28d: float | None = None # Kept for backwards compat
|
|
108
|
+
historical_reward_apy_ltd: float | None = None # Lifetime (legacy)
|
|
65
109
|
|
|
66
110
|
# Bond APY (stETH rebase appreciation)
|
|
67
111
|
bond_apy: float | None = None
|
|
68
112
|
|
|
69
113
|
# Net APY (Historical Reward APY + Bond APY)
|
|
70
|
-
net_apy_28d: float | None = None
|
|
114
|
+
net_apy_28d: float | None = None # Kept for backwards compat
|
|
71
115
|
net_apy_ltd: float | None = None
|
|
72
116
|
|
|
117
|
+
# Full frame history (only populated with --history flag)
|
|
118
|
+
frames: list[DistributionFrame] | None = None
|
|
119
|
+
|
|
120
|
+
# Bond stETH earnings (estimated from stETH rebasing)
|
|
121
|
+
previous_bond_eth: float | None = None
|
|
122
|
+
current_bond_eth: float | None = None
|
|
123
|
+
lifetime_bond_eth: float | None = None
|
|
124
|
+
|
|
125
|
+
# Historical APR values used for each frame (from Lido subgraph)
|
|
126
|
+
previous_bond_apr: float | None = None # APR used for previous frame
|
|
127
|
+
current_bond_apr: float | None = None # APR used for current frame
|
|
128
|
+
|
|
129
|
+
# Track whether historical APR was used (vs fallback to current)
|
|
130
|
+
uses_historical_apr: bool = False
|
|
131
|
+
|
|
132
|
+
# Net total stETH (Rewards + Bond)
|
|
133
|
+
previous_net_total_eth: float | None = None
|
|
134
|
+
current_net_total_eth: float | None = None
|
|
135
|
+
lifetime_net_total_eth: float | None = None
|
|
136
|
+
|
|
73
137
|
# Legacy fields (deprecated, kept for backwards compatibility)
|
|
74
138
|
reward_apy_7d: float | None = None
|
|
75
139
|
reward_apy_28d: float | None = None
|
|
@@ -80,10 +144,11 @@ class StrikeSummary(BaseModel):
|
|
|
80
144
|
"""Summary of strikes for an operator."""
|
|
81
145
|
|
|
82
146
|
total_validators_with_strikes: int = 0
|
|
83
|
-
validators_at_risk: int = 0 # Validators
|
|
84
|
-
validators_near_ejection: int = 0 # Validators
|
|
147
|
+
validators_at_risk: int = 0 # Validators at or above strike threshold (ejection eligible)
|
|
148
|
+
validators_near_ejection: int = 0 # Validators one strike away from ejection
|
|
85
149
|
total_strikes: int = 0
|
|
86
150
|
max_strikes: int = 0 # Highest strike count on any single validator
|
|
151
|
+
strike_threshold: int = 3 # Strikes required for ejection (3 for Permissionless, 4 for ICS)
|
|
87
152
|
|
|
88
153
|
|
|
89
154
|
class HealthStatus(BaseModel):
|
|
@@ -117,6 +182,10 @@ class OperatorRewards(BaseModel):
|
|
|
117
182
|
manager_address: str
|
|
118
183
|
reward_address: str
|
|
119
184
|
|
|
185
|
+
# Operator type (from bond curve)
|
|
186
|
+
curve_id: int = 0 # 0=Permissionless, 1=ICS/Legacy EA
|
|
187
|
+
operator_type: str = "Permissionless" # Human-readable type
|
|
188
|
+
|
|
120
189
|
# Bond information
|
|
121
190
|
current_bond_eth: Decimal
|
|
122
191
|
required_bond_eth: Decimal
|
|
@@ -151,3 +220,6 @@ class OperatorRewards(BaseModel):
|
|
|
151
220
|
|
|
152
221
|
# Health status (optional, requires detailed lookup)
|
|
153
222
|
health: HealthStatus | None = None
|
|
223
|
+
|
|
224
|
+
# Withdrawal history (optional, populated with --history flag)
|
|
225
|
+
withdrawals: list[WithdrawalEvent] | None = None
|
src/data/etherscan.py
CHANGED
|
@@ -76,3 +76,63 @@ class EtherscanProvider:
|
|
|
76
76
|
continue
|
|
77
77
|
|
|
78
78
|
return sorted(results, key=lambda x: x["block"])
|
|
79
|
+
|
|
80
|
+
async def get_transfer_events(
|
|
81
|
+
self,
|
|
82
|
+
token_address: str,
|
|
83
|
+
from_address: str,
|
|
84
|
+
to_address: str,
|
|
85
|
+
from_block: int,
|
|
86
|
+
to_block: str | int = "latest",
|
|
87
|
+
) -> list[dict]:
|
|
88
|
+
"""Query Transfer events from Etherscan for a specific from/to pair."""
|
|
89
|
+
if not self.api_key:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
# Event topic: keccak256("Transfer(address,address,uint256)")
|
|
93
|
+
topic0 = "0x" + Web3.keccak(text="Transfer(address,address,uint256)").hex()
|
|
94
|
+
# topic1 is indexed 'from' address (padded to 32 bytes)
|
|
95
|
+
topic1 = "0x" + from_address.lower().replace("0x", "").zfill(64)
|
|
96
|
+
# topic2 is indexed 'to' address (padded to 32 bytes)
|
|
97
|
+
topic2 = "0x" + to_address.lower().replace("0x", "").zfill(64)
|
|
98
|
+
|
|
99
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
100
|
+
response = await client.get(
|
|
101
|
+
self.BASE_URL,
|
|
102
|
+
params={
|
|
103
|
+
"chainid": 1,
|
|
104
|
+
"module": "logs",
|
|
105
|
+
"action": "getLogs",
|
|
106
|
+
"address": token_address,
|
|
107
|
+
"topic0": topic0,
|
|
108
|
+
"topic1": topic1,
|
|
109
|
+
"topic2": topic2,
|
|
110
|
+
"topic0_1_opr": "and",
|
|
111
|
+
"topic1_2_opr": "and",
|
|
112
|
+
"fromBlock": from_block,
|
|
113
|
+
"toBlock": to_block,
|
|
114
|
+
"apikey": self.api_key,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
data = response.json()
|
|
119
|
+
if data.get("status") != "1":
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
results = []
|
|
123
|
+
for log in data.get("result", []):
|
|
124
|
+
# The data field contains the non-indexed value (amount)
|
|
125
|
+
raw_data = log["data"]
|
|
126
|
+
try:
|
|
127
|
+
value = int(raw_data, 16)
|
|
128
|
+
results.append(
|
|
129
|
+
{
|
|
130
|
+
"block": int(log["blockNumber"], 16),
|
|
131
|
+
"tx_hash": log["transactionHash"],
|
|
132
|
+
"value": value,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
except (ValueError, TypeError):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
return sorted(results, key=lambda x: x["block"])
|