csm-dashboard 0.2.2__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.
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
- 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
- )
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
- console.print(apy_table)
383
- console.print("[dim]*Bond APY uses current stETH rate[/dim]")
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 # Last ~28 days (1 frame)
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
- return operators[op_key].get("distributed_rewards", 0)
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