csm-dashboard 0.2.1__py3-none-any.whl → 0.3.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.1.dist-info → csm_dashboard-0.3.0.dist-info}/METADATA +90 -25
- {csm_dashboard-0.2.1.dist-info → csm_dashboard-0.3.0.dist-info}/RECORD +15 -15
- src/abis/CSAccounting.json +22 -0
- src/abis/stETH.json +10 -0
- src/cli/commands.py +252 -33
- src/core/config.py +3 -0
- src/core/types.py +74 -3
- src/data/etherscan.py +60 -0
- src/data/ipfs_logs.py +42 -2
- src/data/lido_api.py +105 -0
- src/data/onchain.py +190 -0
- src/services/operator_service.py +339 -29
- src/web/routes.py +60 -2
- {csm_dashboard-0.2.1.dist-info → csm_dashboard-0.3.0.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.2.1.dist-info → csm_dashboard-0.3.0.dist-info}/entry_points.txt +0 -0
src/cli/commands.py
CHANGED
|
@@ -25,12 +25,14 @@ 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
37
|
"current_bond_eth": float(rewards.current_bond_eth),
|
|
36
38
|
"required_bond_eth": float(rewards.required_bond_eth),
|
|
@@ -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:
|
|
@@ -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,37 +416,192 @@ 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
|
+
)
|
|
381
488
|
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
384
545
|
console.print()
|
|
385
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
|
+
)
|
|
601
|
+
|
|
602
|
+
console.print(withdrawal_table)
|
|
603
|
+
console.print()
|
|
604
|
+
|
|
386
605
|
|
|
387
606
|
@app.command()
|
|
388
607
|
def health(
|
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
|
|
@@ -117,6 +181,10 @@ class OperatorRewards(BaseModel):
|
|
|
117
181
|
manager_address: str
|
|
118
182
|
reward_address: str
|
|
119
183
|
|
|
184
|
+
# Operator type (from bond curve)
|
|
185
|
+
curve_id: int = 0 # 0=Permissionless, 1=ICS/Legacy EA
|
|
186
|
+
operator_type: str = "Permissionless" # Human-readable type
|
|
187
|
+
|
|
120
188
|
# Bond information
|
|
121
189
|
current_bond_eth: Decimal
|
|
122
190
|
required_bond_eth: Decimal
|
|
@@ -151,3 +219,6 @@ class OperatorRewards(BaseModel):
|
|
|
151
219
|
|
|
152
220
|
# Health status (optional, requires detailed lookup)
|
|
153
221
|
health: HealthStatus | None = None
|
|
222
|
+
|
|
223
|
+
# Withdrawal history (optional, populated with --history flag)
|
|
224
|
+
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"])
|
src/data/ipfs_logs.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import time
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
from decimal import Decimal
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -11,6 +12,19 @@ import httpx
|
|
|
11
12
|
from ..core.config import get_settings
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
# Ethereum Beacon Chain genesis timestamp (Dec 1, 2020 12:00:23 UTC)
|
|
16
|
+
BEACON_GENESIS = 1606824023
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def epoch_to_datetime(epoch: int) -> datetime:
|
|
20
|
+
"""Convert beacon chain epoch to datetime.
|
|
21
|
+
|
|
22
|
+
Each epoch is 32 slots * 12 seconds = 384 seconds.
|
|
23
|
+
"""
|
|
24
|
+
timestamp = BEACON_GENESIS + (epoch * 384)
|
|
25
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
26
|
+
|
|
27
|
+
|
|
14
28
|
@dataclass
|
|
15
29
|
class FrameData:
|
|
16
30
|
"""Data from a single distribution frame."""
|
|
@@ -20,6 +34,7 @@ class FrameData:
|
|
|
20
34
|
log_cid: str
|
|
21
35
|
block_number: int
|
|
22
36
|
distributed_rewards: int # For specific operator, in wei
|
|
37
|
+
validator_count: int # Number of validators for operator in this frame
|
|
23
38
|
|
|
24
39
|
|
|
25
40
|
class IPFSLogProvider:
|
|
@@ -27,7 +42,6 @@ class IPFSLogProvider:
|
|
|
27
42
|
|
|
28
43
|
# IPFS gateways to try in order
|
|
29
44
|
GATEWAYS = [
|
|
30
|
-
"https://dweb.link/ipfs/",
|
|
31
45
|
"https://ipfs.io/ipfs/",
|
|
32
46
|
"https://cloudflare-ipfs.com/ipfs/",
|
|
33
47
|
]
|
|
@@ -113,6 +127,9 @@ class IPFSLogProvider:
|
|
|
113
127
|
Extract operator's distributed_rewards for a frame.
|
|
114
128
|
|
|
115
129
|
Returns rewards in wei (shares), or None if operator not in frame.
|
|
130
|
+
|
|
131
|
+
Note: The IPFS log field name changed from "distributed" to "distributed_rewards"
|
|
132
|
+
around Dec 2025. We check both for backwards compatibility.
|
|
116
133
|
"""
|
|
117
134
|
operators = log_data.get("operators", {})
|
|
118
135
|
op_key = str(operator_id)
|
|
@@ -120,7 +137,12 @@ class IPFSLogProvider:
|
|
|
120
137
|
if op_key not in operators:
|
|
121
138
|
return None
|
|
122
139
|
|
|
123
|
-
|
|
140
|
+
op_data = operators[op_key]
|
|
141
|
+
# Handle both new and old field names for backwards compatibility
|
|
142
|
+
rewards = op_data.get("distributed_rewards")
|
|
143
|
+
if rewards is None:
|
|
144
|
+
rewards = op_data.get("distributed") # Fallback to old field name
|
|
145
|
+
return rewards if rewards is not None else 0
|
|
124
146
|
|
|
125
147
|
def get_frame_info(self, log_data: dict) -> tuple[int, int]:
|
|
126
148
|
"""
|
|
@@ -133,6 +155,22 @@ class IPFSLogProvider:
|
|
|
133
155
|
return (0, 0)
|
|
134
156
|
return (frame[0], frame[1])
|
|
135
157
|
|
|
158
|
+
def get_operator_validator_count(self, log_data: dict, operator_id: int) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Get the number of validators for an operator in a frame.
|
|
161
|
+
|
|
162
|
+
Returns the count of validators, or 0 if operator not in frame.
|
|
163
|
+
"""
|
|
164
|
+
operators = log_data.get("operators", {})
|
|
165
|
+
op_key = str(operator_id)
|
|
166
|
+
|
|
167
|
+
if op_key not in operators:
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
op_data = operators[op_key]
|
|
171
|
+
validators = op_data.get("validators", {})
|
|
172
|
+
return len(validators)
|
|
173
|
+
|
|
136
174
|
async def get_operator_history(
|
|
137
175
|
self,
|
|
138
176
|
operator_id: int,
|
|
@@ -164,6 +202,7 @@ class IPFSLogProvider:
|
|
|
164
202
|
continue
|
|
165
203
|
|
|
166
204
|
start_epoch, end_epoch = self.get_frame_info(log_data)
|
|
205
|
+
validator_count = self.get_operator_validator_count(log_data, operator_id)
|
|
167
206
|
|
|
168
207
|
frames.append(
|
|
169
208
|
FrameData(
|
|
@@ -172,6 +211,7 @@ class IPFSLogProvider:
|
|
|
172
211
|
log_cid=cid,
|
|
173
212
|
block_number=block,
|
|
174
213
|
distributed_rewards=rewards,
|
|
214
|
+
validator_count=validator_count,
|
|
175
215
|
)
|
|
176
216
|
)
|
|
177
217
|
|