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.
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")