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.
@@ -2,7 +2,15 @@
2
2
 
3
3
  from decimal import Decimal
4
4
 
5
- from ..core.types import APYMetrics, BondSummary, HealthStatus, OperatorRewards, StrikeSummary
5
+ from ..core.types import (
6
+ APYMetrics,
7
+ BondSummary,
8
+ DistributionFrame,
9
+ HealthStatus,
10
+ OperatorRewards,
11
+ StrikeSummary,
12
+ WithdrawalEvent,
13
+ )
6
14
  from ..data.beacon import (
7
15
  BeaconDataProvider,
8
16
  ValidatorInfo,
@@ -13,7 +21,7 @@ from ..data.beacon import (
13
21
  epoch_to_datetime,
14
22
  get_earliest_activation,
15
23
  )
16
- from ..data.ipfs_logs import IPFSLogProvider
24
+ from ..data.ipfs_logs import BEACON_GENESIS, IPFSLogProvider, epoch_to_datetime as epoch_to_dt
17
25
  from ..data.lido_api import LidoAPIProvider
18
26
  from ..data.onchain import OnChainDataProvider
19
27
  from ..data.rewards_tree import RewardsTreeProvider
@@ -32,7 +40,7 @@ class OperatorService:
32
40
  self.strikes = StrikesProvider(rpc_url)
33
41
 
34
42
  async def get_operator_by_address(
35
- self, address: str, include_validators: bool = False
43
+ self, address: str, include_validators: bool = False, include_history: bool = False, include_withdrawals: bool = False
36
44
  ) -> OperatorRewards | None:
37
45
  """
38
46
  Main entry point: get complete rewards data for an address.
@@ -43,10 +51,10 @@ class OperatorService:
43
51
  if operator_id is None:
44
52
  return None
45
53
 
46
- return await self.get_operator_by_id(operator_id, include_validators)
54
+ return await self.get_operator_by_id(operator_id, include_validators, include_history, include_withdrawals)
47
55
 
48
56
  async def get_operator_by_id(
49
- self, operator_id: int, include_validators: bool = False
57
+ self, operator_id: int, include_validators: bool = False, include_history: bool = False, include_withdrawals: bool = False
50
58
  ) -> OperatorRewards | None:
51
59
  """Get complete rewards data for an operator ID."""
52
60
  from web3.exceptions import ContractLogicError
@@ -58,36 +66,41 @@ class OperatorService:
58
66
  # Operator ID doesn't exist on-chain
59
67
  return None
60
68
 
61
- # Step 2: Get bond summary
69
+ # Step 2: Get bond curve and operator type
70
+ curve_id = await self.onchain.get_bond_curve_id(operator_id)
71
+ operator_type = self.onchain.get_operator_type_name(curve_id)
72
+
73
+ # Step 3: Get bond summary
62
74
  bond = await self.onchain.get_bond_summary(operator_id)
63
75
 
64
- # Step 3: Get rewards from merkle tree
76
+ # Step 4: Get rewards from merkle tree
65
77
  rewards_info = await self.rewards_tree.get_operator_rewards(operator_id)
66
78
 
67
- # Step 4: Get already distributed (claimed) shares
79
+ # Step 5: Get already distributed (claimed) shares
68
80
  distributed = await self.onchain.get_distributed_shares(operator_id)
69
81
 
70
- # Step 5: Calculate unclaimed
82
+ # Step 6: Calculate unclaimed
71
83
  cumulative_shares = (
72
84
  rewards_info.cumulative_fee_shares if rewards_info else 0
73
85
  )
74
86
  unclaimed_shares = max(0, cumulative_shares - distributed)
75
87
 
76
- # Step 6: Convert shares to ETH
88
+ # Step 7: Convert shares to ETH
77
89
  unclaimed_eth = await self.onchain.shares_to_eth(unclaimed_shares)
78
90
  cumulative_eth = await self.onchain.shares_to_eth(cumulative_shares)
79
91
  distributed_eth = await self.onchain.shares_to_eth(distributed)
80
92
 
81
- # Step 7: Calculate total claimable
93
+ # Step 8: Calculate total claimable
82
94
  total_claimable = bond.excess_bond_eth + unclaimed_eth
83
95
 
84
- # Step 8: Get validator details if requested
96
+ # Step 9: Get validator details if requested
85
97
  validator_details: list[ValidatorInfo] = []
86
98
  validators_by_status: dict[str, int] | None = None
87
99
  avg_effectiveness: float | None = None
88
100
  apy_metrics: APYMetrics | None = None
89
101
  active_since = None
90
102
  health_status: HealthStatus | None = None
103
+ withdrawals: list[WithdrawalEvent] | None = None
91
104
 
92
105
  if include_validators and operator.total_deposited_keys > 0:
93
106
  # Get validator pubkeys
@@ -100,13 +113,15 @@ class OperatorService:
100
113
  avg_effectiveness = calculate_avg_effectiveness(validator_details)
101
114
  active_since = get_earliest_activation(validator_details)
102
115
 
103
- # Step 9: Calculate APY metrics (using historical IPFS data)
116
+ # Step 10: Calculate APY metrics (using historical IPFS data)
104
117
  apy_metrics = await self.calculate_apy_metrics(
105
118
  operator_id=operator_id,
106
119
  bond_eth=bond.current_bond_eth,
120
+ curve_id=curve_id,
121
+ include_history=include_history,
107
122
  )
108
123
 
109
- # Step 10: Calculate health status
124
+ # Step 11: Calculate health status
110
125
  health_status = await self.calculate_health_status(
111
126
  operator_id=operator_id,
112
127
  bond=bond,
@@ -114,10 +129,16 @@ class OperatorService:
114
129
  validator_details=validator_details,
115
130
  )
116
131
 
132
+ # Step 12: Fetch withdrawal history if requested
133
+ if include_withdrawals:
134
+ withdrawals = await self.get_withdrawal_history(operator_id)
135
+
117
136
  return OperatorRewards(
118
137
  node_operator_id=operator_id,
119
138
  manager_address=operator.manager_address,
120
139
  reward_address=operator.reward_address,
140
+ curve_id=curve_id,
141
+ operator_type=operator_type,
121
142
  current_bond_eth=bond.current_bond_eth,
122
143
  required_bond_eth=bond.required_bond_eth,
123
144
  excess_bond_eth=bond.excess_bond_eth,
@@ -137,6 +158,7 @@ class OperatorService:
137
158
  apy=apy_metrics,
138
159
  active_since=active_since,
139
160
  health=health_status,
161
+ withdrawals=withdrawals,
140
162
  )
141
163
 
142
164
  async def get_all_operators_with_rewards(self) -> list[int]:
@@ -147,18 +169,43 @@ class OperatorService:
147
169
  self,
148
170
  operator_id: int,
149
171
  bond_eth: Decimal,
172
+ curve_id: int = 0,
173
+ include_history: bool = False,
150
174
  ) -> APYMetrics:
151
175
  """Calculate APY metrics for an operator using historical IPFS data.
152
176
 
153
177
  Note: Validator APY (consensus rewards) is NOT calculated because CSM operators
154
178
  don't receive those rewards directly - they go to Lido protocol and are
155
179
  redistributed via CSM reward distributions (captured in reward_apy).
180
+
181
+ Args:
182
+ operator_id: The operator ID
183
+ bond_eth: Current bond in ETH
184
+ curve_id: Bond curve (0=Permissionless, 1=ICS/Legacy EA)
185
+ include_history: If True, populate the frames list with all historical data
186
+ and calculate accurate per-frame lifetime APY
156
187
  """
157
188
  historical_reward_apy_28d = None
158
189
  historical_reward_apy_ltd = None
190
+ previous_distribution_eth = None
191
+ previous_distribution_apy = None
192
+ previous_net_apy = None
193
+ current_distribution_eth = None
194
+ current_distribution_apy = None
195
+ next_distribution_date = None
196
+ next_distribution_est_eth = None
197
+ lifetime_distribution_eth = None
198
+ # Accurate lifetime APY (calculated with per-frame bond when include_history=True)
199
+ lifetime_reward_apy = None
200
+ lifetime_bond_apy = None
201
+ lifetime_net_apy = None
202
+ frame_list: list[DistributionFrame] | None = None
203
+ frames = []
159
204
 
160
205
  # 1. Try to get historical APY from IPFS distribution logs
161
- if bond_eth > 0:
206
+ # Minimum bond threshold: 0.01 ETH (dust amounts produce nonsensical APY)
207
+ MIN_BOND_ETH = Decimal("0.01")
208
+ if bond_eth >= MIN_BOND_ETH:
162
209
  try:
163
210
  # Query historical log CIDs from contract events
164
211
  log_history = await self.onchain.get_distribution_log_history()
@@ -170,14 +217,59 @@ class OperatorService:
170
217
  )
171
218
 
172
219
  if frames:
173
- # Calculate APY for 28-day and lifetime periods
174
- apy_results = self.ipfs_logs.calculate_historical_apy(
175
- frames=frames,
176
- bond_eth=bond_eth,
177
- periods=[28, None], # 28-day and lifetime
220
+ # Convert all frame shares to ETH values
221
+ # IPFS logs store distributed_rewards in stETH shares, not ETH
222
+ # We need to convert shares to ETH for accurate display and APY calculation
223
+ total_shares = sum(f.distributed_rewards for f in frames)
224
+ lifetime_distribution_eth = float(
225
+ await self.onchain.shares_to_eth(total_shares)
178
226
  )
179
- historical_reward_apy_28d = apy_results.get("28d")
180
- historical_reward_apy_ltd = apy_results.get("ltd")
227
+
228
+ # Extract current frame data (most recent)
229
+ current_frame = frames[-1]
230
+ current_eth = await self.onchain.shares_to_eth(
231
+ current_frame.distributed_rewards
232
+ )
233
+ current_days = self.ipfs_logs.calculate_frame_duration_days(current_frame)
234
+ current_distribution_eth = float(current_eth)
235
+ if current_days > 0 and bond_eth >= MIN_BOND_ETH:
236
+ current_distribution_apy = round(
237
+ float(current_eth / bond_eth) * (365.0 / current_days) * 100, 2
238
+ )
239
+
240
+ # Extract previous frame data (second-to-last)
241
+ if len(frames) >= 2:
242
+ previous_frame = frames[-2]
243
+ prev_eth = await self.onchain.shares_to_eth(
244
+ previous_frame.distributed_rewards
245
+ )
246
+ prev_days = self.ipfs_logs.calculate_frame_duration_days(previous_frame)
247
+ previous_distribution_eth = float(prev_eth)
248
+ if prev_days > 0 and bond_eth >= MIN_BOND_ETH:
249
+ previous_distribution_apy = round(
250
+ float(prev_eth / bond_eth) * (365.0 / prev_days) * 100, 2
251
+ )
252
+
253
+ # Calculate APY using ETH values (now that we have them)
254
+ # Calculate 28-day APY (current frame)
255
+ if current_distribution_apy is not None:
256
+ historical_reward_apy_28d = current_distribution_apy
257
+ # NOTE: Lifetime APY is intentionally NOT calculated because:
258
+ # - It uses current bond as denominator for all historical rewards
259
+ # - This produces misleading values for operators who grew over time
260
+ # - We keep lifetime_distribution_eth (ETH totals are accurate)
261
+ # historical_reward_apy_ltd remains None
262
+
263
+ # Estimate next distribution date (~28 days after current frame ends)
264
+ # Frame duration ≈ 28 days = ~6300 epochs
265
+ next_epoch = current_frame.end_epoch + 6300
266
+ next_distribution_date = epoch_to_dt(next_epoch).isoformat()
267
+
268
+ # Estimate next distribution ETH based on current daily rate
269
+ if current_days > 0:
270
+ daily_rate = current_eth / Decimal(current_days)
271
+ next_distribution_est_eth = float(daily_rate * Decimal(28))
272
+
181
273
  except Exception:
182
274
  # If historical APY calculation fails, continue without it
183
275
  pass
@@ -186,26 +278,223 @@ class OperatorService:
186
278
  steth_data = await self.lido_api.get_steth_apr()
187
279
  bond_apy = steth_data.get("apr")
188
280
 
189
- # 3. Net APY (Historical Reward APY + Bond APY)
281
+ # 3. Net APY calculations
190
282
  net_apy_28d = None
191
283
  net_apy_ltd = None
192
284
 
285
+ # Current frame net APY (historical_reward_apy_28d is basically current frame APY)
193
286
  if historical_reward_apy_28d is not None and bond_apy is not None:
194
- net_apy_28d = historical_reward_apy_28d + bond_apy
287
+ net_apy_28d = round(historical_reward_apy_28d + bond_apy, 2)
195
288
  elif bond_apy is not None:
196
- net_apy_28d = bond_apy
289
+ net_apy_28d = round(bond_apy, 2)
290
+
291
+ # Lifetime net APY - intentionally NOT calculated
292
+ # (same reason as historical_reward_apy_ltd - can't accurately calculate without historical bond)
293
+ # net_apy_ltd remains None
294
+
295
+ # Previous frame net APY calculation is moved after we know previous_bond_apy
296
+ # (calculated in section 4 below)
297
+
298
+ # 4. Calculate bond stETH earnings (from stETH rebasing)
299
+ # Formula: bond_eth * (apr / 100) * (duration_days / 365)
300
+ # Uses historical APR from Lido subgraph when available
301
+ # When include_history=True and we have per-frame validator counts, use accurate bond
302
+ previous_bond_eth = None
303
+ current_bond_eth = None
304
+ lifetime_bond_eth = None
305
+ previous_net_total_eth = None
306
+ current_net_total_eth = None
307
+ lifetime_net_total_eth = None
308
+ previous_bond_apr = None # Track which APR was used
309
+ current_bond_apr = None
310
+ previous_bond_apy = None # Bond APY for previous frame (for accurate previous_net_apy)
311
+
312
+ # Fetch historical APR data (returns [] if no API key)
313
+ historical_apr_data = await self.lido_api.get_historical_apr_data()
314
+
315
+ if bond_eth >= MIN_BOND_ETH:
316
+ # Previous frame bond earnings
317
+ if frames and len(frames) >= 2:
318
+ prev_frame = frames[-2]
319
+ prev_days = self.ipfs_logs.calculate_frame_duration_days(prev_frame)
320
+ if prev_days > 0:
321
+ # Use average historical APR for the frame period
322
+ prev_start_ts = BEACON_GENESIS + (prev_frame.start_epoch * 384)
323
+ prev_end_ts = BEACON_GENESIS + (prev_frame.end_epoch * 384)
324
+ prev_apr = self.lido_api.get_average_apr_for_range(
325
+ historical_apr_data, prev_start_ts, prev_end_ts
326
+ )
327
+ if prev_apr is None:
328
+ prev_apr = bond_apy
329
+ if prev_apr is not None:
330
+ previous_bond_apr = round(prev_apr, 2)
331
+ previous_bond_apy = previous_bond_apr # Same value, used for net APY
332
+
333
+ # When include_history=True and we have validator count, use per-frame bond
334
+ if include_history and prev_frame.validator_count > 0:
335
+ prev_bond = self.onchain.calculate_required_bond(
336
+ prev_frame.validator_count, curve_id
337
+ )
338
+ previous_bond_eth = round(
339
+ float(prev_bond) * (prev_apr / 100) * (prev_days / 365), 6
340
+ )
341
+ else:
342
+ previous_bond_eth = round(
343
+ float(bond_eth) * (prev_apr / 100) * (prev_days / 365), 6
344
+ )
345
+
346
+ # Current frame bond earnings
347
+ if frames:
348
+ curr_frame = frames[-1]
349
+ curr_days = self.ipfs_logs.calculate_frame_duration_days(curr_frame)
350
+ if curr_days > 0:
351
+ # Use average historical APR for the frame period
352
+ curr_start_ts = BEACON_GENESIS + (curr_frame.start_epoch * 384)
353
+ curr_end_ts = BEACON_GENESIS + (curr_frame.end_epoch * 384)
354
+ curr_apr = self.lido_api.get_average_apr_for_range(
355
+ historical_apr_data, curr_start_ts, curr_end_ts
356
+ )
357
+ if curr_apr is None:
358
+ curr_apr = bond_apy
359
+ if curr_apr is not None:
360
+ current_bond_apr = round(curr_apr, 2)
361
+ current_bond_eth = round(
362
+ float(bond_eth) * (curr_apr / 100) * (curr_days / 365), 6
363
+ )
197
364
 
198
- if historical_reward_apy_ltd is not None and bond_apy is not None:
199
- net_apy_ltd = historical_reward_apy_ltd + bond_apy
200
- elif bond_apy is not None:
201
- net_apy_ltd = bond_apy
365
+ # Lifetime bond earnings (sum of all frame durations with per-frame APR)
366
+ # When include_history=True, calculate accurate lifetime APY with per-frame bond
367
+ if frames:
368
+ lifetime_bond_sum = 0.0
369
+ # For accurate lifetime APY calculation (duration-weighted)
370
+ frame_reward_apys = []
371
+ frame_bond_apys = []
372
+ frame_durations = []
373
+
374
+ for f in frames:
375
+ f_days = self.ipfs_logs.calculate_frame_duration_days(f)
376
+ if f_days > 0:
377
+ # Use average historical APR for each frame period
378
+ f_start_ts = BEACON_GENESIS + (f.start_epoch * 384)
379
+ f_end_ts = BEACON_GENESIS + (f.end_epoch * 384)
380
+ f_apr = self.lido_api.get_average_apr_for_range(
381
+ historical_apr_data, f_start_ts, f_end_ts
382
+ )
383
+ if f_apr is None:
384
+ f_apr = bond_apy
385
+
386
+ if f_apr is not None:
387
+ # When include_history=True and we have validator count, use per-frame bond
388
+ if include_history and f.validator_count > 0:
389
+ f_bond = self.onchain.calculate_required_bond(
390
+ f.validator_count, curve_id
391
+ )
392
+ lifetime_bond_sum += float(f_bond) * (f_apr / 100) * (f_days / 365)
393
+
394
+ # Calculate per-frame reward APY for weighted average
395
+ f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
396
+ if f_bond > 0:
397
+ f_reward_apy = float(f_eth / f_bond) * (365.0 / f_days) * 100
398
+ frame_reward_apys.append(f_reward_apy)
399
+ frame_bond_apys.append(f_apr)
400
+ frame_durations.append(f_days)
401
+ else:
402
+ lifetime_bond_sum += float(bond_eth) * (f_apr / 100) * (f_days / 365)
403
+
404
+ if lifetime_bond_sum > 0:
405
+ lifetime_bond_eth = round(lifetime_bond_sum, 6)
406
+
407
+ # Calculate duration-weighted lifetime APYs when include_history=True
408
+ if include_history and frame_durations:
409
+ total_duration = sum(frame_durations)
410
+ if total_duration > 0:
411
+ # Duration-weighted average reward APY
412
+ lifetime_reward_apy = round(
413
+ sum(apy * dur for apy, dur in zip(frame_reward_apys, frame_durations))
414
+ / total_duration,
415
+ 2
416
+ )
417
+ # Duration-weighted average bond APY
418
+ lifetime_bond_apy = round(
419
+ sum(apy * dur for apy, dur in zip(frame_bond_apys, frame_durations))
420
+ / total_duration,
421
+ 2
422
+ )
423
+ # Net = Reward + Bond
424
+ lifetime_net_apy = round(lifetime_reward_apy + lifetime_bond_apy, 2)
425
+
426
+ # 4b. Previous frame net APY (now that we have previous_bond_apy)
427
+ # Uses the actual APR from the previous frame period instead of current bond_apy
428
+ if previous_distribution_apy is not None:
429
+ prev_bond_apy_to_use = previous_bond_apy if previous_bond_apy is not None else bond_apy
430
+ if prev_bond_apy_to_use is not None:
431
+ previous_net_apy = round(previous_distribution_apy + prev_bond_apy_to_use, 2)
432
+
433
+ # 5. Calculate net totals (Rewards + Bond)
434
+ if previous_distribution_eth is not None or previous_bond_eth is not None:
435
+ previous_net_total_eth = round(
436
+ (previous_distribution_eth or 0) + (previous_bond_eth or 0), 6
437
+ )
438
+ if current_distribution_eth is not None or current_bond_eth is not None:
439
+ current_net_total_eth = round(
440
+ (current_distribution_eth or 0) + (current_bond_eth or 0), 6
441
+ )
442
+ if lifetime_distribution_eth is not None or lifetime_bond_eth is not None:
443
+ lifetime_net_total_eth = round(
444
+ (lifetime_distribution_eth or 0) + (lifetime_bond_eth or 0), 6
445
+ )
446
+
447
+ # 6. Build frame history if requested
448
+ if include_history and frames:
449
+ frame_list = []
450
+ for i, f in enumerate(frames):
451
+ # Convert shares to ETH (not just dividing by 10^18)
452
+ f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
453
+ f_days = self.ipfs_logs.calculate_frame_duration_days(f)
454
+ f_apy = None
455
+ if f_days > 0 and bond_eth >= MIN_BOND_ETH:
456
+ f_apy = round(float(f_eth / bond_eth) * (365.0 / f_days) * 100, 2)
457
+
458
+ frame_list.append(
459
+ DistributionFrame(
460
+ frame_number=i + 1,
461
+ start_date=epoch_to_dt(f.start_epoch).isoformat(),
462
+ end_date=epoch_to_dt(f.end_epoch).isoformat(),
463
+ rewards_eth=float(f_eth),
464
+ rewards_shares=f.distributed_rewards,
465
+ duration_days=round(f_days, 1),
466
+ validator_count=f.validator_count,
467
+ apy=f_apy,
468
+ )
469
+ )
202
470
 
203
471
  return APYMetrics(
472
+ previous_distribution_eth=previous_distribution_eth,
473
+ previous_distribution_apy=previous_distribution_apy,
474
+ previous_net_apy=previous_net_apy,
475
+ current_distribution_eth=current_distribution_eth,
476
+ current_distribution_apy=current_distribution_apy,
477
+ next_distribution_date=next_distribution_date,
478
+ next_distribution_est_eth=next_distribution_est_eth,
479
+ lifetime_distribution_eth=lifetime_distribution_eth,
480
+ lifetime_reward_apy=lifetime_reward_apy,
481
+ lifetime_bond_apy=lifetime_bond_apy,
482
+ lifetime_net_apy=lifetime_net_apy,
204
483
  historical_reward_apy_28d=historical_reward_apy_28d,
205
484
  historical_reward_apy_ltd=historical_reward_apy_ltd,
206
485
  bond_apy=bond_apy,
207
486
  net_apy_28d=net_apy_28d,
208
487
  net_apy_ltd=net_apy_ltd,
488
+ frames=frame_list,
489
+ previous_bond_eth=previous_bond_eth,
490
+ current_bond_eth=current_bond_eth,
491
+ lifetime_bond_eth=lifetime_bond_eth,
492
+ previous_bond_apr=previous_bond_apr,
493
+ current_bond_apr=current_bond_apr,
494
+ uses_historical_apr=bool(historical_apr_data),
495
+ previous_net_total_eth=previous_net_total_eth,
496
+ current_net_total_eth=current_net_total_eth,
497
+ lifetime_net_total_eth=lifetime_net_total_eth,
209
498
  )
210
499
 
211
500
  async def calculate_health_status(
@@ -318,3 +607,24 @@ class OperatorService:
318
607
  return get_earliest_activation(validators)
319
608
  except Exception:
320
609
  return None
610
+
611
+ async def get_withdrawal_history(self, operator_id: int) -> list[WithdrawalEvent]:
612
+ """Get withdrawal/claim history for an operator.
613
+
614
+ Returns list of WithdrawalEvent objects representing when rewards were claimed.
615
+ """
616
+ try:
617
+ operator = await self.onchain.get_node_operator(operator_id)
618
+ events = await self.onchain.get_withdrawal_history(operator.reward_address)
619
+ return [
620
+ WithdrawalEvent(
621
+ block_number=e["block_number"],
622
+ timestamp=e["timestamp"],
623
+ shares=e["shares"],
624
+ eth_value=e["eth_value"],
625
+ tx_hash=e["tx_hash"],
626
+ )
627
+ for e in events
628
+ ]
629
+ except Exception:
630
+ return []
src/web/routes.py CHANGED
@@ -11,6 +11,8 @@ router = APIRouter()
11
11
  async def get_operator(
12
12
  identifier: str,
13
13
  detailed: bool = Query(False, description="Include validator status from beacon chain"),
14
+ history: bool = Query(False, description="Include all historical distribution frames"),
15
+ withdrawals: bool = Query(False, description="Include withdrawal/claim history"),
14
16
  ):
15
17
  """
16
18
  Get operator data by address or ID.
@@ -18,6 +20,8 @@ async def get_operator(
18
20
  - If identifier is numeric, treat as operator ID
19
21
  - If identifier starts with 0x, treat as Ethereum address
20
22
  - Add ?detailed=true to include validator status breakdown
23
+ - Add ?history=true to include all historical distribution frames
24
+ - Add ?withdrawals=true to include withdrawal/claim history
21
25
  """
22
26
  service = OperatorService()
23
27
 
@@ -26,9 +30,9 @@ async def get_operator(
26
30
  operator_id = int(identifier)
27
31
  if operator_id < 0 or operator_id > 1_000_000:
28
32
  raise HTTPException(status_code=400, detail="Invalid operator ID")
29
- rewards = await service.get_operator_by_id(operator_id, detailed)
33
+ rewards = await service.get_operator_by_id(operator_id, detailed or history, history, withdrawals)
30
34
  elif identifier.startswith("0x"):
31
- rewards = await service.get_operator_by_address(identifier, detailed)
35
+ rewards = await service.get_operator_by_address(identifier, detailed or history, history, withdrawals)
32
36
  else:
33
37
  raise HTTPException(status_code=400, detail="Invalid identifier format")
34
38
 
@@ -39,6 +43,8 @@ async def get_operator(
39
43
  "operator_id": rewards.node_operator_id,
40
44
  "manager_address": rewards.manager_address,
41
45
  "reward_address": rewards.reward_address,
46
+ "curve_id": rewards.curve_id,
47
+ "operator_type": rewards.operator_type,
42
48
  "rewards": {
43
49
  "current_bond_eth": float(rewards.current_bond_eth),
44
50
  "required_bond_eth": float(rewards.required_bond_eth),
@@ -79,13 +85,65 @@ async def get_operator(
79
85
 
80
86
  # Add APY metrics if available
81
87
  if rewards.apy:
88
+ # Use actual excess bond for lifetime values (estimates for previous/current)
89
+ lifetime_bond = float(rewards.excess_bond_eth)
90
+ lifetime_net_total = (rewards.apy.lifetime_distribution_eth or 0) + lifetime_bond
82
91
  result["apy"] = {
92
+ "previous_distribution_eth": rewards.apy.previous_distribution_eth,
93
+ "previous_distribution_apy": rewards.apy.previous_distribution_apy,
94
+ "previous_net_apy": rewards.apy.previous_net_apy,
95
+ "previous_bond_eth": rewards.apy.previous_bond_eth,
96
+ "previous_bond_apr": rewards.apy.previous_bond_apr,
97
+ "previous_net_total_eth": rewards.apy.previous_net_total_eth,
98
+ "current_distribution_eth": rewards.apy.current_distribution_eth,
99
+ "current_distribution_apy": rewards.apy.current_distribution_apy,
100
+ "current_bond_eth": rewards.apy.current_bond_eth,
101
+ "current_bond_apr": rewards.apy.current_bond_apr,
102
+ "current_net_total_eth": rewards.apy.current_net_total_eth,
103
+ "lifetime_distribution_eth": rewards.apy.lifetime_distribution_eth,
104
+ "lifetime_bond_eth": lifetime_bond, # Actual excess bond, not estimate
105
+ "lifetime_net_total_eth": lifetime_net_total, # Matches Total Claimable
106
+ # Accurate lifetime APY (per-frame bond calculation when available)
107
+ "lifetime_reward_apy": rewards.apy.lifetime_reward_apy,
108
+ "lifetime_bond_apy": rewards.apy.lifetime_bond_apy,
109
+ "lifetime_net_apy": rewards.apy.lifetime_net_apy,
110
+ "next_distribution_date": rewards.apy.next_distribution_date,
111
+ "next_distribution_est_eth": rewards.apy.next_distribution_est_eth,
83
112
  "historical_reward_apy_28d": rewards.apy.historical_reward_apy_28d,
84
113
  "historical_reward_apy_ltd": rewards.apy.historical_reward_apy_ltd,
85
114
  "bond_apy": rewards.apy.bond_apy,
86
115
  "net_apy_28d": rewards.apy.net_apy_28d,
87
116
  "net_apy_ltd": rewards.apy.net_apy_ltd,
117
+ "uses_historical_apr": rewards.apy.uses_historical_apr,
88
118
  }
119
+ # Add frames if available (from history=true)
120
+ if rewards.apy.frames:
121
+ result["apy"]["frames"] = [
122
+ {
123
+ "frame_number": f.frame_number,
124
+ "start_date": f.start_date,
125
+ "end_date": f.end_date,
126
+ "rewards_eth": f.rewards_eth,
127
+ "rewards_shares": f.rewards_shares,
128
+ "duration_days": f.duration_days,
129
+ "validator_count": f.validator_count,
130
+ "apy": f.apy,
131
+ }
132
+ for f in rewards.apy.frames
133
+ ]
134
+
135
+ # Add withdrawal history if withdrawals=true (already fetched during data gathering)
136
+ if withdrawals and rewards.withdrawals:
137
+ result["withdrawals"] = [
138
+ {
139
+ "block_number": w.block_number,
140
+ "timestamp": w.timestamp,
141
+ "shares": w.shares,
142
+ "eth_value": w.eth_value,
143
+ "tx_hash": w.tx_hash,
144
+ }
145
+ for w in rewards.withdrawals
146
+ ]
89
147
 
90
148
  # Add active_since if available
91
149
  if rewards.active_since: