csm-dashboard 0.2.0__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.0.dist-info/METADATA +354 -0
- csm_dashboard-0.2.0.dist-info/RECORD +27 -0
- csm_dashboard-0.2.0.dist-info/WHEEL +4 -0
- csm_dashboard-0.2.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/cli/__init__.py +1 -0
- src/cli/commands.py +624 -0
- src/core/__init__.py +1 -0
- src/core/config.py +42 -0
- src/core/contracts.py +19 -0
- src/core/types.py +153 -0
- src/data/__init__.py +1 -0
- src/data/beacon.py +370 -0
- src/data/cache.py +67 -0
- src/data/etherscan.py +78 -0
- src/data/ipfs_logs.py +267 -0
- src/data/known_cids.py +30 -0
- src/data/lido_api.py +35 -0
- src/data/onchain.py +258 -0
- src/data/rewards_tree.py +58 -0
- src/data/strikes.py +214 -0
- src/main.py +39 -0
- src/services/__init__.py +1 -0
- src/services/operator_service.py +320 -0
- src/web/__init__.py +1 -0
- src/web/app.py +576 -0
- src/web/routes.py +161 -0
src/cli/commands.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Typer CLI commands with Rich formatting."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ..core.types import OperatorRewards
|
|
14
|
+
from ..services.operator_service import OperatorService
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="csm",
|
|
18
|
+
help="Lido CSM Operator Dashboard - Track your validator earnings",
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_async(coro):
|
|
24
|
+
"""Helper to run async functions from sync CLI."""
|
|
25
|
+
return asyncio.run(coro)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_as_api_json(rewards: OperatorRewards, include_validators: bool = False) -> dict:
|
|
29
|
+
"""Format rewards data in the same structure as the API endpoint."""
|
|
30
|
+
result = {
|
|
31
|
+
"operator_id": rewards.node_operator_id,
|
|
32
|
+
"manager_address": rewards.manager_address,
|
|
33
|
+
"reward_address": rewards.reward_address,
|
|
34
|
+
"rewards": {
|
|
35
|
+
"current_bond_eth": float(rewards.current_bond_eth),
|
|
36
|
+
"required_bond_eth": float(rewards.required_bond_eth),
|
|
37
|
+
"excess_bond_eth": float(rewards.excess_bond_eth),
|
|
38
|
+
"cumulative_rewards_shares": rewards.cumulative_rewards_shares,
|
|
39
|
+
"cumulative_rewards_eth": float(rewards.cumulative_rewards_eth),
|
|
40
|
+
"distributed_shares": rewards.distributed_shares,
|
|
41
|
+
"distributed_eth": float(rewards.distributed_eth),
|
|
42
|
+
"unclaimed_shares": rewards.unclaimed_shares,
|
|
43
|
+
"unclaimed_eth": float(rewards.unclaimed_eth),
|
|
44
|
+
"total_claimable_eth": float(rewards.total_claimable_eth),
|
|
45
|
+
},
|
|
46
|
+
"validators": {
|
|
47
|
+
"total": rewards.total_validators,
|
|
48
|
+
"active": rewards.active_validators,
|
|
49
|
+
"exited": rewards.exited_validators,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Add beacon chain validator details if available
|
|
54
|
+
if rewards.validators_by_status:
|
|
55
|
+
result["validators"]["by_status"] = rewards.validators_by_status
|
|
56
|
+
|
|
57
|
+
if rewards.avg_effectiveness is not None:
|
|
58
|
+
result["performance"] = {
|
|
59
|
+
"avg_effectiveness": round(rewards.avg_effectiveness, 2),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if include_validators and rewards.validator_details:
|
|
63
|
+
result["validator_details"] = [v.to_dict() for v in rewards.validator_details]
|
|
64
|
+
|
|
65
|
+
# Add APY metrics if available
|
|
66
|
+
if rewards.apy:
|
|
67
|
+
result["apy"] = {
|
|
68
|
+
"historical_reward_apy_28d": rewards.apy.historical_reward_apy_28d,
|
|
69
|
+
"historical_reward_apy_ltd": rewards.apy.historical_reward_apy_ltd,
|
|
70
|
+
"bond_apy": rewards.apy.bond_apy,
|
|
71
|
+
"net_apy_28d": rewards.apy.net_apy_28d,
|
|
72
|
+
"net_apy_ltd": rewards.apy.net_apy_ltd,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Add active_since if available
|
|
76
|
+
if rewards.active_since:
|
|
77
|
+
result["active_since"] = rewards.active_since.isoformat()
|
|
78
|
+
|
|
79
|
+
# Add health status if available
|
|
80
|
+
if rewards.health:
|
|
81
|
+
result["health"] = {
|
|
82
|
+
"bond_healthy": rewards.health.bond_healthy,
|
|
83
|
+
"bond_deficit_eth": float(rewards.health.bond_deficit_eth),
|
|
84
|
+
"stuck_validators_count": rewards.health.stuck_validators_count,
|
|
85
|
+
"slashed_validators_count": rewards.health.slashed_validators_count,
|
|
86
|
+
"validators_at_risk_count": rewards.health.validators_at_risk_count,
|
|
87
|
+
"strikes": {
|
|
88
|
+
"total_validators_with_strikes": rewards.health.strikes.total_validators_with_strikes,
|
|
89
|
+
"validators_at_risk": rewards.health.strikes.validators_at_risk,
|
|
90
|
+
"validators_near_ejection": rewards.health.strikes.validators_near_ejection,
|
|
91
|
+
"total_strikes": rewards.health.strikes.total_strikes,
|
|
92
|
+
"max_strikes": rewards.health.strikes.max_strikes,
|
|
93
|
+
},
|
|
94
|
+
"has_issues": rewards.health.has_issues,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("rewards")
|
|
101
|
+
@app.command("check", hidden=True)
|
|
102
|
+
def rewards(
|
|
103
|
+
address: Optional[str] = typer.Argument(
|
|
104
|
+
None, help="Ethereum address (required unless --id is provided)"
|
|
105
|
+
),
|
|
106
|
+
operator_id: Optional[int] = typer.Option(
|
|
107
|
+
None, "--id", "-i", help="Operator ID (skip address lookup)"
|
|
108
|
+
),
|
|
109
|
+
rpc_url: Optional[str] = typer.Option(
|
|
110
|
+
None, "--rpc", "-r", help="Custom RPC URL"
|
|
111
|
+
),
|
|
112
|
+
output_json: bool = typer.Option(
|
|
113
|
+
False, "--json", "-j", help="Output as JSON (same format as API)"
|
|
114
|
+
),
|
|
115
|
+
detailed: bool = typer.Option(
|
|
116
|
+
False, "--detailed", "-d", help="Include validator status from beacon chain"
|
|
117
|
+
),
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Check CSM operator rewards and earnings.
|
|
121
|
+
|
|
122
|
+
Examples:
|
|
123
|
+
csm rewards 0xYourAddress
|
|
124
|
+
csm rewards 42
|
|
125
|
+
csm rewards 0xYourAddress --json
|
|
126
|
+
csm rewards 42 --detailed
|
|
127
|
+
"""
|
|
128
|
+
if address is None and operator_id is None:
|
|
129
|
+
console.print("[red]Error: Must provide either ADDRESS or --id[/red]")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
|
|
132
|
+
# Parse numeric address as operator ID
|
|
133
|
+
if address is not None and address.isdigit():
|
|
134
|
+
operator_id = int(address)
|
|
135
|
+
address = None
|
|
136
|
+
|
|
137
|
+
service = OperatorService(rpc_url)
|
|
138
|
+
|
|
139
|
+
if not output_json:
|
|
140
|
+
console.print()
|
|
141
|
+
status_msg = "[bold blue]Fetching operator data..."
|
|
142
|
+
if detailed:
|
|
143
|
+
status_msg = "[bold blue]Fetching operator data and validator status..."
|
|
144
|
+
with console.status(status_msg):
|
|
145
|
+
if operator_id is not None:
|
|
146
|
+
rewards = run_async(service.get_operator_by_id(operator_id, detailed))
|
|
147
|
+
else:
|
|
148
|
+
console.print(f"[dim]Looking up operator for address: {address}[/dim]")
|
|
149
|
+
rewards = run_async(service.get_operator_by_address(address, detailed))
|
|
150
|
+
else:
|
|
151
|
+
# JSON mode - no status output
|
|
152
|
+
if operator_id is not None:
|
|
153
|
+
rewards = run_async(service.get_operator_by_id(operator_id, detailed))
|
|
154
|
+
else:
|
|
155
|
+
rewards = run_async(service.get_operator_by_address(address, detailed))
|
|
156
|
+
|
|
157
|
+
if rewards is None:
|
|
158
|
+
if output_json:
|
|
159
|
+
print(json.dumps({"error": "Operator not found"}, indent=2))
|
|
160
|
+
else:
|
|
161
|
+
console.print("[red]No CSM operator found for this address/ID[/red]")
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
# JSON output mode
|
|
165
|
+
if output_json:
|
|
166
|
+
print(json.dumps(format_as_api_json(rewards, detailed), indent=2))
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Header panel
|
|
170
|
+
active_since_str = ""
|
|
171
|
+
if rewards.active_since:
|
|
172
|
+
active_since_str = f"Active Since: {rewards.active_since.strftime('%b %d, %Y')}"
|
|
173
|
+
console.print(
|
|
174
|
+
Panel(
|
|
175
|
+
f"[bold]CSM Operator #{rewards.node_operator_id}[/bold]\n"
|
|
176
|
+
f"{active_since_str}\n\n"
|
|
177
|
+
f"Manager: {rewards.manager_address}\n"
|
|
178
|
+
f"Rewards: {rewards.reward_address}",
|
|
179
|
+
title="Operator Info",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Validators table
|
|
184
|
+
val_table = Table(title="Validators", show_header=False)
|
|
185
|
+
val_table.add_column("Metric", style="cyan")
|
|
186
|
+
val_table.add_column("Value", style="green")
|
|
187
|
+
val_table.add_row("Total Validators", str(rewards.total_validators))
|
|
188
|
+
val_table.add_row("Active Validators", str(rewards.active_validators))
|
|
189
|
+
val_table.add_row("Exited Validators", str(rewards.exited_validators))
|
|
190
|
+
console.print(val_table)
|
|
191
|
+
console.print()
|
|
192
|
+
|
|
193
|
+
# Validator status breakdown (from beacon chain) - shown right after validators table
|
|
194
|
+
if detailed and rewards.validators_by_status:
|
|
195
|
+
status_table = Table(title="Validator Status (Beacon Chain)")
|
|
196
|
+
status_table.add_column("Status", style="cyan")
|
|
197
|
+
status_table.add_column("Count", style="green", justify="right")
|
|
198
|
+
|
|
199
|
+
status_order = ["active", "pending", "exiting", "exited", "slashed", "unknown"]
|
|
200
|
+
status_styles = {
|
|
201
|
+
"active": "green",
|
|
202
|
+
"pending": "yellow",
|
|
203
|
+
"exiting": "yellow",
|
|
204
|
+
"exited": "dim",
|
|
205
|
+
"slashed": "red bold",
|
|
206
|
+
"unknown": "dim",
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for status in status_order:
|
|
210
|
+
count = rewards.validators_by_status.get(status, 0)
|
|
211
|
+
if count > 0:
|
|
212
|
+
style = status_styles.get(status, "white")
|
|
213
|
+
status_table.add_row(
|
|
214
|
+
status.capitalize(),
|
|
215
|
+
f"[{style}]{count}[/{style}]",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
console.print(status_table)
|
|
219
|
+
|
|
220
|
+
if rewards.avg_effectiveness is not None:
|
|
221
|
+
console.print(
|
|
222
|
+
f"\n[cyan]Average Attestation Effectiveness:[/cyan] "
|
|
223
|
+
f"[bold green]{rewards.avg_effectiveness:.1f}%[/bold green]"
|
|
224
|
+
)
|
|
225
|
+
console.print()
|
|
226
|
+
|
|
227
|
+
# Health Status - shown right after validator status
|
|
228
|
+
if detailed and rewards.health:
|
|
229
|
+
health_table = Table(title="Health Status")
|
|
230
|
+
health_table.add_column("Check", style="cyan")
|
|
231
|
+
health_table.add_column("Status", justify="right")
|
|
232
|
+
|
|
233
|
+
# Bond health
|
|
234
|
+
if rewards.health.bond_healthy:
|
|
235
|
+
health_table.add_row("Bond", "[green]HEALTHY[/green]")
|
|
236
|
+
else:
|
|
237
|
+
health_table.add_row(
|
|
238
|
+
"Bond",
|
|
239
|
+
f"[red bold]DEFICIT -{rewards.health.bond_deficit_eth:.4f} ETH[/red bold]"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Stuck validators
|
|
243
|
+
if rewards.health.stuck_validators_count == 0:
|
|
244
|
+
health_table.add_row("Stuck Validators", "[green]0[/green]")
|
|
245
|
+
else:
|
|
246
|
+
health_table.add_row(
|
|
247
|
+
"Stuck Validators",
|
|
248
|
+
f"[red bold]{rewards.health.stuck_validators_count}[/red bold] (exit within 4 days!)"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Slashed validators
|
|
252
|
+
if rewards.health.slashed_validators_count == 0:
|
|
253
|
+
health_table.add_row("Slashed", "[green]0[/green]")
|
|
254
|
+
else:
|
|
255
|
+
health_table.add_row(
|
|
256
|
+
"Slashed",
|
|
257
|
+
f"[red bold]{rewards.health.slashed_validators_count}[/red bold] (est. 1-33 ETH penalty each)"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# At-risk validators (balance < 32 ETH)
|
|
261
|
+
if rewards.health.validators_at_risk_count == 0:
|
|
262
|
+
health_table.add_row("At Risk (<32 ETH)", "[green]0[/green]")
|
|
263
|
+
else:
|
|
264
|
+
health_table.add_row(
|
|
265
|
+
"At Risk (<32 ETH)",
|
|
266
|
+
f"[yellow]{rewards.health.validators_at_risk_count}[/yellow]"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Strikes
|
|
270
|
+
strikes = rewards.health.strikes
|
|
271
|
+
if strikes.total_validators_with_strikes == 0:
|
|
272
|
+
health_table.add_row("Performance Strikes", "[green]0/3[/green]")
|
|
273
|
+
else:
|
|
274
|
+
# Build strike status message
|
|
275
|
+
strike_parts = []
|
|
276
|
+
if strikes.validators_at_risk > 0:
|
|
277
|
+
strike_parts.append(f"{strikes.validators_at_risk} at ejection")
|
|
278
|
+
if strikes.validators_near_ejection > 0:
|
|
279
|
+
strike_parts.append(f"{strikes.validators_near_ejection} near ejection")
|
|
280
|
+
|
|
281
|
+
strike_status = ", ".join(strike_parts) if strike_parts else "monitoring"
|
|
282
|
+
strike_style = "red bold" if strikes.validators_at_risk > 0 else (
|
|
283
|
+
"bright_yellow" if strikes.validators_near_ejection > 0 else "yellow"
|
|
284
|
+
)
|
|
285
|
+
health_table.add_row(
|
|
286
|
+
"Performance Strikes",
|
|
287
|
+
f"[{strike_style}]{strikes.total_validators_with_strikes} validators[/{strike_style}] "
|
|
288
|
+
f"({strike_status})"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
console.print(health_table)
|
|
292
|
+
|
|
293
|
+
# Overall status - color-coded by severity
|
|
294
|
+
if not rewards.health.has_issues:
|
|
295
|
+
console.print("\n[bold green]Overall: No issues detected[/bold green]")
|
|
296
|
+
elif (
|
|
297
|
+
not rewards.health.bond_healthy
|
|
298
|
+
or rewards.health.stuck_validators_count > 0
|
|
299
|
+
or rewards.health.slashed_validators_count > 0
|
|
300
|
+
or rewards.health.validators_at_risk_count > 0
|
|
301
|
+
or strikes.max_strikes >= 3
|
|
302
|
+
):
|
|
303
|
+
# Critical issues (red)
|
|
304
|
+
console.print("\n[bold red]Overall: Issues detected - review above[/bold red]")
|
|
305
|
+
elif strikes.max_strikes == 2:
|
|
306
|
+
# Warning level 2 (orange/bright yellow)
|
|
307
|
+
console.print("\n[bold bright_yellow]Overall: Warning - 2 strikes detected[/bold bright_yellow]")
|
|
308
|
+
else:
|
|
309
|
+
# Warning level 1 (yellow)
|
|
310
|
+
console.print("\n[bold yellow]Overall: Warning - strikes detected[/bold yellow]")
|
|
311
|
+
console.print()
|
|
312
|
+
|
|
313
|
+
# Rewards table
|
|
314
|
+
rewards_table = Table(title="Earnings Summary")
|
|
315
|
+
rewards_table.add_column("Metric", style="cyan")
|
|
316
|
+
rewards_table.add_column("Value", style="green")
|
|
317
|
+
rewards_table.add_column("Notes", style="dim")
|
|
318
|
+
|
|
319
|
+
rewards_table.add_row(
|
|
320
|
+
"Current Bond",
|
|
321
|
+
f"{rewards.current_bond_eth:.6f} ETH",
|
|
322
|
+
f"Required: {rewards.required_bond_eth:.6f} ETH",
|
|
323
|
+
)
|
|
324
|
+
rewards_table.add_row(
|
|
325
|
+
"Excess Bond",
|
|
326
|
+
f"[bold green]{rewards.excess_bond_eth:.6f} ETH[/bold green]",
|
|
327
|
+
"Claimable",
|
|
328
|
+
)
|
|
329
|
+
rewards_table.add_row("", "", "")
|
|
330
|
+
rewards_table.add_row(
|
|
331
|
+
"Cumulative Rewards",
|
|
332
|
+
f"{rewards.cumulative_rewards_eth:.6f} ETH",
|
|
333
|
+
f"({rewards.cumulative_rewards_shares:,} shares)" if detailed else "All-time total",
|
|
334
|
+
)
|
|
335
|
+
rewards_table.add_row(
|
|
336
|
+
"Already Distributed",
|
|
337
|
+
f"{rewards.distributed_eth:.6f} ETH",
|
|
338
|
+
f"({rewards.distributed_shares:,} shares)" if detailed else "",
|
|
339
|
+
)
|
|
340
|
+
rewards_table.add_row(
|
|
341
|
+
"Unclaimed Rewards",
|
|
342
|
+
f"[bold green]{rewards.unclaimed_eth:.6f} ETH[/bold green]",
|
|
343
|
+
f"({rewards.unclaimed_shares:,} shares)" if detailed else "",
|
|
344
|
+
)
|
|
345
|
+
rewards_table.add_row("", "", "")
|
|
346
|
+
rewards_table.add_row(
|
|
347
|
+
"[bold]TOTAL CLAIMABLE[/bold]",
|
|
348
|
+
f"[bold yellow]{rewards.total_claimable_eth:.6f} ETH[/bold yellow]",
|
|
349
|
+
"Excess bond + unclaimed rewards",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
console.print(rewards_table)
|
|
353
|
+
console.print()
|
|
354
|
+
|
|
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
|
+
|
|
362
|
+
def fmt_apy(val: float | None) -> str:
|
|
363
|
+
return f"{val:.2f}%" if val is not None else "--"
|
|
364
|
+
|
|
365
|
+
apy_table.add_row(
|
|
366
|
+
"Reward APY",
|
|
367
|
+
fmt_apy(rewards.apy.historical_reward_apy_28d),
|
|
368
|
+
fmt_apy(rewards.apy.historical_reward_apy_ltd),
|
|
369
|
+
)
|
|
370
|
+
apy_table.add_row(
|
|
371
|
+
"Bond APY (stETH)*",
|
|
372
|
+
fmt_apy(rewards.apy.bond_apy),
|
|
373
|
+
fmt_apy(rewards.apy.bond_apy),
|
|
374
|
+
)
|
|
375
|
+
apy_table.add_row("", "", "")
|
|
376
|
+
apy_table.add_row(
|
|
377
|
+
"[bold]NET APY[/bold]",
|
|
378
|
+
f"[bold yellow]{fmt_apy(rewards.apy.net_apy_28d)}[/bold yellow]",
|
|
379
|
+
f"[bold yellow]{fmt_apy(rewards.apy.net_apy_ltd)}[/bold yellow]",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
console.print(apy_table)
|
|
383
|
+
console.print("[dim]*Bond APY uses current stETH rate[/dim]")
|
|
384
|
+
console.print()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.command()
|
|
388
|
+
def health(
|
|
389
|
+
address: Optional[str] = typer.Argument(
|
|
390
|
+
None, help="Ethereum address (required unless --id is provided)"
|
|
391
|
+
),
|
|
392
|
+
operator_id: Optional[int] = typer.Option(
|
|
393
|
+
None, "--id", "-i", help="Operator ID (skip address lookup)"
|
|
394
|
+
),
|
|
395
|
+
rpc_url: Optional[str] = typer.Option(
|
|
396
|
+
None, "--rpc", "-r", help="Custom RPC URL"
|
|
397
|
+
),
|
|
398
|
+
output_json: bool = typer.Option(
|
|
399
|
+
False, "--json", "-j", help="Output as JSON"
|
|
400
|
+
),
|
|
401
|
+
):
|
|
402
|
+
"""
|
|
403
|
+
Check CSM operator health status - penalties, strikes, and risks.
|
|
404
|
+
|
|
405
|
+
Examples:
|
|
406
|
+
csm health 0xYourAddress
|
|
407
|
+
csm health 42
|
|
408
|
+
csm health --id 42 --json
|
|
409
|
+
"""
|
|
410
|
+
if address is None and operator_id is None:
|
|
411
|
+
console.print("[red]Error: Must provide either ADDRESS or --id[/red]")
|
|
412
|
+
raise typer.Exit(1)
|
|
413
|
+
|
|
414
|
+
# Parse numeric address as operator ID
|
|
415
|
+
if address is not None and address.isdigit():
|
|
416
|
+
operator_id = int(address)
|
|
417
|
+
address = None
|
|
418
|
+
|
|
419
|
+
service = OperatorService(rpc_url)
|
|
420
|
+
|
|
421
|
+
if not output_json:
|
|
422
|
+
console.print()
|
|
423
|
+
with console.status("[bold blue]Fetching operator health status..."):
|
|
424
|
+
if operator_id is not None:
|
|
425
|
+
rewards = run_async(service.get_operator_by_id(operator_id, True))
|
|
426
|
+
else:
|
|
427
|
+
console.print(f"[dim]Looking up operator for address: {address}[/dim]")
|
|
428
|
+
rewards = run_async(service.get_operator_by_address(address, True))
|
|
429
|
+
else:
|
|
430
|
+
if operator_id is not None:
|
|
431
|
+
rewards = run_async(service.get_operator_by_id(operator_id, True))
|
|
432
|
+
else:
|
|
433
|
+
rewards = run_async(service.get_operator_by_address(address, True))
|
|
434
|
+
|
|
435
|
+
if rewards is None:
|
|
436
|
+
if output_json:
|
|
437
|
+
print(json.dumps({"error": "Operator not found"}, indent=2))
|
|
438
|
+
else:
|
|
439
|
+
console.print("[red]No CSM operator found for this address/ID[/red]")
|
|
440
|
+
raise typer.Exit(1)
|
|
441
|
+
|
|
442
|
+
# JSON output
|
|
443
|
+
if output_json:
|
|
444
|
+
result = {"operator_id": rewards.node_operator_id}
|
|
445
|
+
if rewards.health:
|
|
446
|
+
# Fetch validator strikes details
|
|
447
|
+
validator_strikes = []
|
|
448
|
+
if rewards.health.strikes.total_validators_with_strikes > 0:
|
|
449
|
+
strikes_data = run_async(service.get_operator_strikes(rewards.node_operator_id))
|
|
450
|
+
validator_strikes = [
|
|
451
|
+
{
|
|
452
|
+
"pubkey": vs.pubkey,
|
|
453
|
+
"strike_count": vs.strike_count,
|
|
454
|
+
"at_ejection_risk": vs.at_ejection_risk,
|
|
455
|
+
}
|
|
456
|
+
for vs in strikes_data
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
result["health"] = {
|
|
460
|
+
"bond_healthy": rewards.health.bond_healthy,
|
|
461
|
+
"bond_deficit_eth": float(rewards.health.bond_deficit_eth),
|
|
462
|
+
"stuck_validators_count": rewards.health.stuck_validators_count,
|
|
463
|
+
"slashed_validators_count": rewards.health.slashed_validators_count,
|
|
464
|
+
"validators_at_risk_count": rewards.health.validators_at_risk_count,
|
|
465
|
+
"strikes": {
|
|
466
|
+
"total_validators_with_strikes": rewards.health.strikes.total_validators_with_strikes,
|
|
467
|
+
"validators_at_risk": rewards.health.strikes.validators_at_risk,
|
|
468
|
+
"validators_near_ejection": rewards.health.strikes.validators_near_ejection,
|
|
469
|
+
"total_strikes": rewards.health.strikes.total_strikes,
|
|
470
|
+
"max_strikes": rewards.health.strikes.max_strikes,
|
|
471
|
+
"validators": validator_strikes,
|
|
472
|
+
},
|
|
473
|
+
"has_issues": rewards.health.has_issues,
|
|
474
|
+
}
|
|
475
|
+
print(json.dumps(result, indent=2))
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
# Rich output
|
|
479
|
+
console.print(
|
|
480
|
+
Panel(
|
|
481
|
+
f"[bold]CSM Operator #{rewards.node_operator_id} Health Status[/bold]",
|
|
482
|
+
title="Health Check",
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if not rewards.health:
|
|
487
|
+
console.print("[yellow]Health status not available[/yellow]")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
health = rewards.health
|
|
491
|
+
|
|
492
|
+
# Build health status panel content
|
|
493
|
+
lines = []
|
|
494
|
+
|
|
495
|
+
# Bond status
|
|
496
|
+
if health.bond_healthy:
|
|
497
|
+
lines.append(f"Bond: [green]HEALTHY[/green] (excess: {rewards.excess_bond_eth:.4f} ETH)")
|
|
498
|
+
else:
|
|
499
|
+
lines.append(f"Bond: [red bold]DEFICIT -{health.bond_deficit_eth:.4f} ETH[/red bold]")
|
|
500
|
+
|
|
501
|
+
# Stuck validators
|
|
502
|
+
if health.stuck_validators_count == 0:
|
|
503
|
+
lines.append("Stuck: [green]0 validators[/green]")
|
|
504
|
+
else:
|
|
505
|
+
lines.append(f"Stuck: [red bold]{health.stuck_validators_count} validators[/red bold] (exit within 4 days!)")
|
|
506
|
+
|
|
507
|
+
# Slashed
|
|
508
|
+
if health.slashed_validators_count == 0:
|
|
509
|
+
lines.append("Slashed: [green]0 validators[/green]")
|
|
510
|
+
else:
|
|
511
|
+
lines.append(f"Slashed: [red bold]{health.slashed_validators_count} validators[/red bold]")
|
|
512
|
+
|
|
513
|
+
# At risk
|
|
514
|
+
if health.validators_at_risk_count == 0:
|
|
515
|
+
lines.append("At Risk: [green]0 validators[/green] (<32 ETH balance)")
|
|
516
|
+
else:
|
|
517
|
+
lines.append(f"At Risk: [yellow]{health.validators_at_risk_count} validators[/yellow] (<32 ETH balance)")
|
|
518
|
+
|
|
519
|
+
# Strikes
|
|
520
|
+
strikes = health.strikes
|
|
521
|
+
if strikes.total_validators_with_strikes == 0:
|
|
522
|
+
lines.append("Strikes: [green]0 validators[/green]")
|
|
523
|
+
else:
|
|
524
|
+
# Build strike status message
|
|
525
|
+
strike_parts = []
|
|
526
|
+
if strikes.validators_at_risk > 0:
|
|
527
|
+
strike_parts.append(f"{strikes.validators_at_risk} at ejection")
|
|
528
|
+
if strikes.validators_near_ejection > 0:
|
|
529
|
+
strike_parts.append(f"{strikes.validators_near_ejection} near ejection")
|
|
530
|
+
|
|
531
|
+
strike_status = ", ".join(strike_parts) if strike_parts else "monitoring"
|
|
532
|
+
strike_style = "red bold" if strikes.validators_at_risk > 0 else (
|
|
533
|
+
"bright_yellow" if strikes.validators_near_ejection > 0 else "yellow"
|
|
534
|
+
)
|
|
535
|
+
lines.append(
|
|
536
|
+
f"Strikes: [{strike_style}]{strikes.total_validators_with_strikes} validators[/{strike_style}] "
|
|
537
|
+
f"({strike_status})"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
lines.append("")
|
|
541
|
+
|
|
542
|
+
# Overall status - color-coded by severity
|
|
543
|
+
if not health.has_issues:
|
|
544
|
+
lines.append("[bold green]Overall: No issues detected[/bold green]")
|
|
545
|
+
elif (
|
|
546
|
+
not health.bond_healthy
|
|
547
|
+
or health.stuck_validators_count > 0
|
|
548
|
+
or health.slashed_validators_count > 0
|
|
549
|
+
or health.validators_at_risk_count > 0
|
|
550
|
+
or strikes.max_strikes >= 3
|
|
551
|
+
):
|
|
552
|
+
# Critical issues (red)
|
|
553
|
+
lines.append("[bold red]Overall: Issues detected - action required![/bold red]")
|
|
554
|
+
elif strikes.max_strikes == 2:
|
|
555
|
+
# Warning level 2 (orange/bright yellow)
|
|
556
|
+
lines.append("[bold bright_yellow]Overall: Warning - 2 strikes detected[/bold bright_yellow]")
|
|
557
|
+
else:
|
|
558
|
+
# Warning level 1 (yellow)
|
|
559
|
+
lines.append("[bold yellow]Overall: Warning - strikes detected[/bold yellow]")
|
|
560
|
+
|
|
561
|
+
console.print(Panel("\n".join(lines), title="Status"))
|
|
562
|
+
|
|
563
|
+
# Show detailed strikes if any
|
|
564
|
+
if strikes.total_validators_with_strikes > 0:
|
|
565
|
+
console.print()
|
|
566
|
+
console.print("[bold]Validator Strikes Detail:[/bold]")
|
|
567
|
+
validator_strikes = run_async(service.get_operator_strikes(rewards.node_operator_id))
|
|
568
|
+
for vs in validator_strikes:
|
|
569
|
+
strike_display = f"{vs.strike_count}/3"
|
|
570
|
+
if vs.at_ejection_risk:
|
|
571
|
+
console.print(f" {vs.pubkey}: [red bold]{strike_display}[/red bold] (EJECTION RISK!)")
|
|
572
|
+
elif vs.strike_count > 0:
|
|
573
|
+
console.print(f" {vs.pubkey}: [yellow]{strike_display}[/yellow]")
|
|
574
|
+
else:
|
|
575
|
+
console.print(f" {vs.pubkey}: [green]{strike_display}[/green]")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@app.command()
|
|
579
|
+
def watch(
|
|
580
|
+
address: str = typer.Argument(..., help="Ethereum address to monitor"),
|
|
581
|
+
interval: int = typer.Option(
|
|
582
|
+
300, "--interval", "-i", help="Refresh interval in seconds"
|
|
583
|
+
),
|
|
584
|
+
rpc_url: Optional[str] = typer.Option(
|
|
585
|
+
None, "--rpc", "-r", help="Custom RPC URL"
|
|
586
|
+
),
|
|
587
|
+
):
|
|
588
|
+
"""
|
|
589
|
+
Continuously monitor rewards with live updates.
|
|
590
|
+
Press Ctrl+C to stop.
|
|
591
|
+
"""
|
|
592
|
+
try:
|
|
593
|
+
while True:
|
|
594
|
+
console.clear()
|
|
595
|
+
try:
|
|
596
|
+
rewards(address, rpc_url=rpc_url)
|
|
597
|
+
except Exception as e:
|
|
598
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
599
|
+
console.print(
|
|
600
|
+
f"\n[dim]Refreshing every {interval} seconds... Press Ctrl+C to stop[/dim]"
|
|
601
|
+
)
|
|
602
|
+
time.sleep(interval)
|
|
603
|
+
except KeyboardInterrupt:
|
|
604
|
+
console.print("\n[yellow]Watch stopped.[/yellow]")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@app.command(name="list")
|
|
608
|
+
def list_operators(
|
|
609
|
+
rpc_url: Optional[str] = typer.Option(
|
|
610
|
+
None, "--rpc", "-r", help="Custom RPC URL"
|
|
611
|
+
),
|
|
612
|
+
):
|
|
613
|
+
"""List all operators with rewards in the current tree."""
|
|
614
|
+
service = OperatorService(rpc_url)
|
|
615
|
+
|
|
616
|
+
with console.status("[bold blue]Fetching rewards tree..."):
|
|
617
|
+
operator_ids = run_async(service.get_all_operators_with_rewards())
|
|
618
|
+
|
|
619
|
+
console.print(f"\n[bold]Found {len(operator_ids)} operators with rewards:[/bold]")
|
|
620
|
+
console.print(", ".join(str(op_id) for op_id in operator_ids))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
if __name__ == "__main__":
|
|
624
|
+
app()
|
src/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core module - configuration, types, and contracts."""
|
src/core/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Configuration management using pydantic-settings."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings(BaseSettings):
|
|
9
|
+
"""Application settings loaded from environment variables."""
|
|
10
|
+
|
|
11
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
# RPC Configuration
|
|
14
|
+
eth_rpc_url: str = "https://eth.llamarpc.com"
|
|
15
|
+
|
|
16
|
+
# Beacon Chain API (optional)
|
|
17
|
+
beacon_api_url: str = "https://beaconcha.in/api/v1"
|
|
18
|
+
beacon_api_key: str | None = None # Optional beaconcha.in API key for higher rate limits
|
|
19
|
+
|
|
20
|
+
# Etherscan API (optional, for historical event queries)
|
|
21
|
+
etherscan_api_key: str | None = None
|
|
22
|
+
|
|
23
|
+
# Data Sources
|
|
24
|
+
rewards_proofs_url: str = (
|
|
25
|
+
"https://raw.githubusercontent.com/lidofinance/csm-rewards/mainnet/proofs.json"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Cache Settings
|
|
29
|
+
cache_ttl_seconds: int = 300 # 5 minutes
|
|
30
|
+
|
|
31
|
+
# Contract Addresses (Mainnet)
|
|
32
|
+
csmodule_address: str = "0xdA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F"
|
|
33
|
+
csaccounting_address: str = "0x4d72BFF1BeaC69925F8Bd12526a39BAAb069e5Da"
|
|
34
|
+
csfeedistributor_address: str = "0xD99CC66fEC647E68294C6477B40fC7E0F6F618D0"
|
|
35
|
+
csstrikes_address: str = "0xaa328816027F2D32B9F56d190BC9Fa4A5C07637f"
|
|
36
|
+
steth_address: str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache
|
|
40
|
+
def get_settings() -> Settings:
|
|
41
|
+
"""Get cached settings instance."""
|
|
42
|
+
return Settings()
|
src/core/contracts.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Contract ABIs and helpers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_abi(name: str) -> list[dict[str, Any]]:
|
|
9
|
+
"""Load ABI from JSON file in abis directory."""
|
|
10
|
+
abi_path = Path(__file__).parent.parent.parent / "abis" / f"{name}.json"
|
|
11
|
+
with open(abi_path) as f:
|
|
12
|
+
return json.load(f)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Load ABIs at module level for easy import
|
|
16
|
+
CSMODULE_ABI = load_abi("CSModule")
|
|
17
|
+
CSACCOUNTING_ABI = load_abi("CSAccounting")
|
|
18
|
+
CSFEEDISTRIBUTOR_ABI = load_abi("CSFeeDistributor")
|
|
19
|
+
STETH_ABI = load_abi("stETH")
|