csm-dashboard 0.2.2__py3-none-any.whl → 0.3.1__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,24 +113,33 @@ 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,
113
128
  stuck_validators_count=operator.stuck_validators_count,
114
129
  validator_details=validator_details,
130
+ curve_id=curve_id,
115
131
  )
116
132
 
133
+ # Step 12: Fetch withdrawal history if requested
134
+ if include_withdrawals:
135
+ withdrawals = await self.get_withdrawal_history(operator_id)
136
+
117
137
  return OperatorRewards(
118
138
  node_operator_id=operator_id,
119
139
  manager_address=operator.manager_address,
120
140
  reward_address=operator.reward_address,
141
+ curve_id=curve_id,
142
+ operator_type=operator_type,
121
143
  current_bond_eth=bond.current_bond_eth,
122
144
  required_bond_eth=bond.required_bond_eth,
123
145
  excess_bond_eth=bond.excess_bond_eth,
@@ -137,6 +159,7 @@ class OperatorService:
137
159
  apy=apy_metrics,
138
160
  active_since=active_since,
139
161
  health=health_status,
162
+ withdrawals=withdrawals,
140
163
  )
141
164
 
142
165
  async def get_all_operators_with_rewards(self) -> list[int]:
@@ -147,18 +170,43 @@ class OperatorService:
147
170
  self,
148
171
  operator_id: int,
149
172
  bond_eth: Decimal,
173
+ curve_id: int = 0,
174
+ include_history: bool = False,
150
175
  ) -> APYMetrics:
151
176
  """Calculate APY metrics for an operator using historical IPFS data.
152
177
 
153
178
  Note: Validator APY (consensus rewards) is NOT calculated because CSM operators
154
179
  don't receive those rewards directly - they go to Lido protocol and are
155
180
  redistributed via CSM reward distributions (captured in reward_apy).
181
+
182
+ Args:
183
+ operator_id: The operator ID
184
+ bond_eth: Current bond in ETH
185
+ curve_id: Bond curve (0=Permissionless, 1=ICS/Legacy EA)
186
+ include_history: If True, populate the frames list with all historical data
187
+ and calculate accurate per-frame lifetime APY
156
188
  """
157
189
  historical_reward_apy_28d = None
158
190
  historical_reward_apy_ltd = None
191
+ previous_distribution_eth = None
192
+ previous_distribution_apy = None
193
+ previous_net_apy = None
194
+ current_distribution_eth = None
195
+ current_distribution_apy = None
196
+ next_distribution_date = None
197
+ next_distribution_est_eth = None
198
+ lifetime_distribution_eth = None
199
+ # Accurate lifetime APY (calculated with per-frame bond when include_history=True)
200
+ lifetime_reward_apy = None
201
+ lifetime_bond_apy = None
202
+ lifetime_net_apy = None
203
+ frame_list: list[DistributionFrame] | None = None
204
+ frames = []
159
205
 
160
206
  # 1. Try to get historical APY from IPFS distribution logs
161
- if bond_eth > 0:
207
+ # Minimum bond threshold: 0.01 ETH (dust amounts produce nonsensical APY)
208
+ MIN_BOND_ETH = Decimal("0.01")
209
+ if bond_eth >= MIN_BOND_ETH:
162
210
  try:
163
211
  # Query historical log CIDs from contract events
164
212
  log_history = await self.onchain.get_distribution_log_history()
@@ -170,14 +218,59 @@ class OperatorService:
170
218
  )
171
219
 
172
220
  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
221
+ # Convert all frame shares to ETH values
222
+ # IPFS logs store distributed_rewards in stETH shares, not ETH
223
+ # We need to convert shares to ETH for accurate display and APY calculation
224
+ total_shares = sum(f.distributed_rewards for f in frames)
225
+ lifetime_distribution_eth = float(
226
+ await self.onchain.shares_to_eth(total_shares)
178
227
  )
179
- historical_reward_apy_28d = apy_results.get("28d")
180
- historical_reward_apy_ltd = apy_results.get("ltd")
228
+
229
+ # Extract current frame data (most recent)
230
+ current_frame = frames[-1]
231
+ current_eth = await self.onchain.shares_to_eth(
232
+ current_frame.distributed_rewards
233
+ )
234
+ current_days = self.ipfs_logs.calculate_frame_duration_days(current_frame)
235
+ current_distribution_eth = float(current_eth)
236
+ if current_days > 0 and bond_eth >= MIN_BOND_ETH:
237
+ current_distribution_apy = round(
238
+ float(current_eth / bond_eth) * (365.0 / current_days) * 100, 2
239
+ )
240
+
241
+ # Extract previous frame data (second-to-last)
242
+ if len(frames) >= 2:
243
+ previous_frame = frames[-2]
244
+ prev_eth = await self.onchain.shares_to_eth(
245
+ previous_frame.distributed_rewards
246
+ )
247
+ prev_days = self.ipfs_logs.calculate_frame_duration_days(previous_frame)
248
+ previous_distribution_eth = float(prev_eth)
249
+ if prev_days > 0 and bond_eth >= MIN_BOND_ETH:
250
+ previous_distribution_apy = round(
251
+ float(prev_eth / bond_eth) * (365.0 / prev_days) * 100, 2
252
+ )
253
+
254
+ # Calculate APY using ETH values (now that we have them)
255
+ # Calculate 28-day APY (current frame)
256
+ if current_distribution_apy is not None:
257
+ historical_reward_apy_28d = current_distribution_apy
258
+ # NOTE: Lifetime APY is intentionally NOT calculated because:
259
+ # - It uses current bond as denominator for all historical rewards
260
+ # - This produces misleading values for operators who grew over time
261
+ # - We keep lifetime_distribution_eth (ETH totals are accurate)
262
+ # historical_reward_apy_ltd remains None
263
+
264
+ # Estimate next distribution date (~28 days after current frame ends)
265
+ # Frame duration ≈ 28 days = ~6300 epochs
266
+ next_epoch = current_frame.end_epoch + 6300
267
+ next_distribution_date = epoch_to_dt(next_epoch).isoformat()
268
+
269
+ # Estimate next distribution ETH based on current daily rate
270
+ if current_days > 0:
271
+ daily_rate = current_eth / Decimal(current_days)
272
+ next_distribution_est_eth = float(daily_rate * Decimal(28))
273
+
181
274
  except Exception:
182
275
  # If historical APY calculation fails, continue without it
183
276
  pass
@@ -186,26 +279,223 @@ class OperatorService:
186
279
  steth_data = await self.lido_api.get_steth_apr()
187
280
  bond_apy = steth_data.get("apr")
188
281
 
189
- # 3. Net APY (Historical Reward APY + Bond APY)
282
+ # 3. Net APY calculations
190
283
  net_apy_28d = None
191
284
  net_apy_ltd = None
192
285
 
286
+ # Current frame net APY (historical_reward_apy_28d is basically current frame APY)
193
287
  if historical_reward_apy_28d is not None and bond_apy is not None:
194
- net_apy_28d = historical_reward_apy_28d + bond_apy
288
+ net_apy_28d = round(historical_reward_apy_28d + bond_apy, 2)
195
289
  elif bond_apy is not None:
196
- net_apy_28d = bond_apy
290
+ net_apy_28d = round(bond_apy, 2)
291
+
292
+ # Lifetime net APY - intentionally NOT calculated
293
+ # (same reason as historical_reward_apy_ltd - can't accurately calculate without historical bond)
294
+ # net_apy_ltd remains None
295
+
296
+ # Previous frame net APY calculation is moved after we know previous_bond_apy
297
+ # (calculated in section 4 below)
298
+
299
+ # 4. Calculate bond stETH earnings (from stETH rebasing)
300
+ # Formula: bond_eth * (apr / 100) * (duration_days / 365)
301
+ # Uses historical APR from Lido subgraph when available
302
+ # When include_history=True and we have per-frame validator counts, use accurate bond
303
+ previous_bond_eth = None
304
+ current_bond_eth = None
305
+ lifetime_bond_eth = None
306
+ previous_net_total_eth = None
307
+ current_net_total_eth = None
308
+ lifetime_net_total_eth = None
309
+ previous_bond_apr = None # Track which APR was used
310
+ current_bond_apr = None
311
+ previous_bond_apy = None # Bond APY for previous frame (for accurate previous_net_apy)
312
+
313
+ # Fetch historical APR data (returns [] if no API key)
314
+ historical_apr_data = await self.lido_api.get_historical_apr_data()
315
+
316
+ if bond_eth >= MIN_BOND_ETH:
317
+ # Previous frame bond earnings
318
+ if frames and len(frames) >= 2:
319
+ prev_frame = frames[-2]
320
+ prev_days = self.ipfs_logs.calculate_frame_duration_days(prev_frame)
321
+ if prev_days > 0:
322
+ # Use average historical APR for the frame period
323
+ prev_start_ts = BEACON_GENESIS + (prev_frame.start_epoch * 384)
324
+ prev_end_ts = BEACON_GENESIS + (prev_frame.end_epoch * 384)
325
+ prev_apr = self.lido_api.get_average_apr_for_range(
326
+ historical_apr_data, prev_start_ts, prev_end_ts
327
+ )
328
+ if prev_apr is None:
329
+ prev_apr = bond_apy
330
+ if prev_apr is not None:
331
+ previous_bond_apr = round(prev_apr, 2)
332
+ previous_bond_apy = previous_bond_apr # Same value, used for net APY
333
+
334
+ # When include_history=True and we have validator count, use per-frame bond
335
+ if include_history and prev_frame.validator_count > 0:
336
+ prev_bond = self.onchain.calculate_required_bond(
337
+ prev_frame.validator_count, curve_id
338
+ )
339
+ previous_bond_eth = round(
340
+ float(prev_bond) * (prev_apr / 100) * (prev_days / 365), 6
341
+ )
342
+ else:
343
+ previous_bond_eth = round(
344
+ float(bond_eth) * (prev_apr / 100) * (prev_days / 365), 6
345
+ )
346
+
347
+ # Current frame bond earnings
348
+ if frames:
349
+ curr_frame = frames[-1]
350
+ curr_days = self.ipfs_logs.calculate_frame_duration_days(curr_frame)
351
+ if curr_days > 0:
352
+ # Use average historical APR for the frame period
353
+ curr_start_ts = BEACON_GENESIS + (curr_frame.start_epoch * 384)
354
+ curr_end_ts = BEACON_GENESIS + (curr_frame.end_epoch * 384)
355
+ curr_apr = self.lido_api.get_average_apr_for_range(
356
+ historical_apr_data, curr_start_ts, curr_end_ts
357
+ )
358
+ if curr_apr is None:
359
+ curr_apr = bond_apy
360
+ if curr_apr is not None:
361
+ current_bond_apr = round(curr_apr, 2)
362
+ current_bond_eth = round(
363
+ float(bond_eth) * (curr_apr / 100) * (curr_days / 365), 6
364
+ )
197
365
 
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
366
+ # Lifetime bond earnings (sum of all frame durations with per-frame APR)
367
+ # When include_history=True, calculate accurate lifetime APY with per-frame bond
368
+ if frames:
369
+ lifetime_bond_sum = 0.0
370
+ # For accurate lifetime APY calculation (duration-weighted)
371
+ frame_reward_apys = []
372
+ frame_bond_apys = []
373
+ frame_durations = []
374
+
375
+ for f in frames:
376
+ f_days = self.ipfs_logs.calculate_frame_duration_days(f)
377
+ if f_days > 0:
378
+ # Use average historical APR for each frame period
379
+ f_start_ts = BEACON_GENESIS + (f.start_epoch * 384)
380
+ f_end_ts = BEACON_GENESIS + (f.end_epoch * 384)
381
+ f_apr = self.lido_api.get_average_apr_for_range(
382
+ historical_apr_data, f_start_ts, f_end_ts
383
+ )
384
+ if f_apr is None:
385
+ f_apr = bond_apy
386
+
387
+ if f_apr is not None:
388
+ # When include_history=True and we have validator count, use per-frame bond
389
+ if include_history and f.validator_count > 0:
390
+ f_bond = self.onchain.calculate_required_bond(
391
+ f.validator_count, curve_id
392
+ )
393
+ lifetime_bond_sum += float(f_bond) * (f_apr / 100) * (f_days / 365)
394
+
395
+ # Calculate per-frame reward APY for weighted average
396
+ f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
397
+ if f_bond > 0:
398
+ f_reward_apy = float(f_eth / f_bond) * (365.0 / f_days) * 100
399
+ frame_reward_apys.append(f_reward_apy)
400
+ frame_bond_apys.append(f_apr)
401
+ frame_durations.append(f_days)
402
+ else:
403
+ lifetime_bond_sum += float(bond_eth) * (f_apr / 100) * (f_days / 365)
404
+
405
+ if lifetime_bond_sum > 0:
406
+ lifetime_bond_eth = round(lifetime_bond_sum, 6)
407
+
408
+ # Calculate duration-weighted lifetime APYs when include_history=True
409
+ if include_history and frame_durations:
410
+ total_duration = sum(frame_durations)
411
+ if total_duration > 0:
412
+ # Duration-weighted average reward APY
413
+ lifetime_reward_apy = round(
414
+ sum(apy * dur for apy, dur in zip(frame_reward_apys, frame_durations))
415
+ / total_duration,
416
+ 2
417
+ )
418
+ # Duration-weighted average bond APY
419
+ lifetime_bond_apy = round(
420
+ sum(apy * dur for apy, dur in zip(frame_bond_apys, frame_durations))
421
+ / total_duration,
422
+ 2
423
+ )
424
+ # Net = Reward + Bond
425
+ lifetime_net_apy = round(lifetime_reward_apy + lifetime_bond_apy, 2)
426
+
427
+ # 4b. Previous frame net APY (now that we have previous_bond_apy)
428
+ # Uses the actual APR from the previous frame period instead of current bond_apy
429
+ if previous_distribution_apy is not None:
430
+ prev_bond_apy_to_use = previous_bond_apy if previous_bond_apy is not None else bond_apy
431
+ if prev_bond_apy_to_use is not None:
432
+ previous_net_apy = round(previous_distribution_apy + prev_bond_apy_to_use, 2)
433
+
434
+ # 5. Calculate net totals (Rewards + Bond)
435
+ if previous_distribution_eth is not None or previous_bond_eth is not None:
436
+ previous_net_total_eth = round(
437
+ (previous_distribution_eth or 0) + (previous_bond_eth or 0), 6
438
+ )
439
+ if current_distribution_eth is not None or current_bond_eth is not None:
440
+ current_net_total_eth = round(
441
+ (current_distribution_eth or 0) + (current_bond_eth or 0), 6
442
+ )
443
+ if lifetime_distribution_eth is not None or lifetime_bond_eth is not None:
444
+ lifetime_net_total_eth = round(
445
+ (lifetime_distribution_eth or 0) + (lifetime_bond_eth or 0), 6
446
+ )
447
+
448
+ # 6. Build frame history if requested
449
+ if include_history and frames:
450
+ frame_list = []
451
+ for i, f in enumerate(frames):
452
+ # Convert shares to ETH (not just dividing by 10^18)
453
+ f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
454
+ f_days = self.ipfs_logs.calculate_frame_duration_days(f)
455
+ f_apy = None
456
+ if f_days > 0 and bond_eth >= MIN_BOND_ETH:
457
+ f_apy = round(float(f_eth / bond_eth) * (365.0 / f_days) * 100, 2)
458
+
459
+ frame_list.append(
460
+ DistributionFrame(
461
+ frame_number=i + 1,
462
+ start_date=epoch_to_dt(f.start_epoch).isoformat(),
463
+ end_date=epoch_to_dt(f.end_epoch).isoformat(),
464
+ rewards_eth=float(f_eth),
465
+ rewards_shares=f.distributed_rewards,
466
+ duration_days=round(f_days, 1),
467
+ validator_count=f.validator_count,
468
+ apy=f_apy,
469
+ )
470
+ )
202
471
 
203
472
  return APYMetrics(
473
+ previous_distribution_eth=previous_distribution_eth,
474
+ previous_distribution_apy=previous_distribution_apy,
475
+ previous_net_apy=previous_net_apy,
476
+ current_distribution_eth=current_distribution_eth,
477
+ current_distribution_apy=current_distribution_apy,
478
+ next_distribution_date=next_distribution_date,
479
+ next_distribution_est_eth=next_distribution_est_eth,
480
+ lifetime_distribution_eth=lifetime_distribution_eth,
481
+ lifetime_reward_apy=lifetime_reward_apy,
482
+ lifetime_bond_apy=lifetime_bond_apy,
483
+ lifetime_net_apy=lifetime_net_apy,
204
484
  historical_reward_apy_28d=historical_reward_apy_28d,
205
485
  historical_reward_apy_ltd=historical_reward_apy_ltd,
206
486
  bond_apy=bond_apy,
207
487
  net_apy_28d=net_apy_28d,
208
488
  net_apy_ltd=net_apy_ltd,
489
+ frames=frame_list,
490
+ previous_bond_eth=previous_bond_eth,
491
+ current_bond_eth=current_bond_eth,
492
+ lifetime_bond_eth=lifetime_bond_eth,
493
+ previous_bond_apr=previous_bond_apr,
494
+ current_bond_apr=current_bond_apr,
495
+ uses_historical_apr=bool(historical_apr_data),
496
+ previous_net_total_eth=previous_net_total_eth,
497
+ current_net_total_eth=current_net_total_eth,
498
+ lifetime_net_total_eth=lifetime_net_total_eth,
209
499
  )
210
500
 
211
501
  async def calculate_health_status(
@@ -214,6 +504,7 @@ class OperatorService:
214
504
  bond: BondSummary,
215
505
  stuck_validators_count: int,
216
506
  validator_details: list[ValidatorInfo],
507
+ curve_id: int | None = None,
217
508
  ) -> HealthStatus:
218
509
  """Calculate health status for an operator.
219
510
 
@@ -227,16 +518,17 @@ class OperatorService:
227
518
  slashed_count = count_slashed_validators(validator_details)
228
519
  at_risk_count = count_at_risk_validators(validator_details)
229
520
 
230
- # Get strikes data
521
+ # Get strikes data (pass curve_id for operator-specific thresholds)
231
522
  strike_summary = StrikeSummary()
232
523
  try:
233
- summary = await self.strikes.get_operator_strike_summary(operator_id)
524
+ summary = await self.strikes.get_operator_strike_summary(operator_id, curve_id)
234
525
  strike_summary = StrikeSummary(
235
526
  total_validators_with_strikes=summary.get("total_validators_with_strikes", 0),
236
527
  validators_at_risk=summary.get("validators_at_risk", 0),
237
528
  validators_near_ejection=summary.get("validators_near_ejection", 0),
238
529
  total_strikes=summary.get("total_strikes", 0),
239
530
  max_strikes=summary.get("max_strikes", 0),
531
+ strike_threshold=summary.get("strike_threshold", 3),
240
532
  )
241
533
  except Exception:
242
534
  # If strikes fetch fails, continue with empty summary
@@ -251,9 +543,14 @@ class OperatorService:
251
543
  strikes=strike_summary,
252
544
  )
253
545
 
254
- async def get_operator_strikes(self, operator_id: int):
255
- """Get detailed strikes for an operator's validators."""
256
- return await self.strikes.get_operator_strikes(operator_id)
546
+ async def get_operator_strikes(self, operator_id: int, curve_id: int | None = None):
547
+ """Get detailed strikes for an operator's validators.
548
+
549
+ Args:
550
+ operator_id: The CSM operator ID
551
+ curve_id: The operator's bond curve ID (determines strike threshold)
552
+ """
553
+ return await self.strikes.get_operator_strikes(operator_id, curve_id)
257
554
 
258
555
  async def get_recent_frame_dates(self, count: int = 6) -> list[dict]:
259
556
  """Get date ranges for the most recent N distribution frames.
@@ -318,3 +615,24 @@ class OperatorService:
318
615
  return get_earliest_activation(validators)
319
616
  except Exception:
320
617
  return None
618
+
619
+ async def get_withdrawal_history(self, operator_id: int) -> list[WithdrawalEvent]:
620
+ """Get withdrawal/claim history for an operator.
621
+
622
+ Returns list of WithdrawalEvent objects representing when rewards were claimed.
623
+ """
624
+ try:
625
+ operator = await self.onchain.get_node_operator(operator_id)
626
+ events = await self.onchain.get_withdrawal_history(operator.reward_address)
627
+ return [
628
+ WithdrawalEvent(
629
+ block_number=e["block_number"],
630
+ timestamp=e["timestamp"],
631
+ shares=e["shares"],
632
+ eth_value=e["eth_value"],
633
+ tx_hash=e["tx_hash"],
634
+ )
635
+ for e in events
636
+ ]
637
+ except Exception:
638
+ return []
src/web/app.py CHANGED
@@ -473,9 +473,11 @@ def create_app() -> FastAPI:
473
473
  const opId = document.getElementById('operator-id').textContent;
474
474
  const strikesResp = await fetch(`/api/operator/${opId}/strikes`);
475
475
  const strikesData = await strikesResp.json();
476
+ const threshold = strikesData.strike_threshold || 3;
476
477
  strikesList.innerHTML = strikesData.validators.map(v => {
478
+ const vThreshold = v.strike_threshold || threshold;
477
479
  const colorClass = v.at_ejection_risk ? 'text-red-400' :
478
- (v.strike_count === 2 ? 'text-orange-400' : 'text-yellow-400');
480
+ (v.strike_count === vThreshold - 1 ? 'text-orange-400' : 'text-yellow-400');
479
481
 
480
482
  // Generate 6 dots with date tooltips
481
483
  const dots = v.strikes.map((strike, i) => {
@@ -497,7 +499,7 @@ def create_app() -> FastAPI:
497
499
  <a href="${beaconUrl}" target="_blank" rel="noopener"
498
500
  class="text-blue-400 hover:text-blue-300 text-sm" title="View on beaconcha.in">↗</a>
499
501
  <span class="flex gap-0.5 text-base ml-1">${dots}</span>
500
- <span class="text-gray-400 text-xs">(${v.strike_count}/3)</span>
502
+ <span class="text-gray-400 text-xs">(${v.strike_count}/${vThreshold})</span>
501
503
  </div>`;
502
504
  }).join('');
503
505
  strikesLoaded = true;
@@ -533,6 +535,7 @@ def create_app() -> FastAPI:
533
535
  }
534
536
 
535
537
  // Overall - color-coded by severity
538
+ const strikeThreshold = h.strikes.strike_threshold || 3;
536
539
  if (!h.has_issues) {
537
540
  document.getElementById('health-overall').innerHTML = '<span class="text-green-400">No issues detected</span>';
538
541
  } else if (
@@ -540,18 +543,18 @@ def create_app() -> FastAPI:
540
543
  h.stuck_validators_count > 0 ||
541
544
  h.slashed_validators_count > 0 ||
542
545
  h.validators_at_risk_count > 0 ||
543
- h.strikes.max_strikes >= 3
546
+ h.strikes.max_strikes >= strikeThreshold
544
547
  ) {
545
548
  // Critical issues (red)
546
549
  let message = 'Issues detected - action required!';
547
- if (h.strikes.max_strikes >= 3) {
548
- message = `Validator ejectable (${h.strikes.validators_at_risk} at 3/3 strikes)`;
550
+ if (h.strikes.max_strikes >= strikeThreshold) {
551
+ message = `Validator ejectable (${h.strikes.validators_at_risk} at ${strikeThreshold}/${strikeThreshold} strikes)`;
549
552
  }
550
553
  document.getElementById('health-overall').innerHTML = `<span class="text-red-400">${message}</span>`;
551
- } else if (h.strikes.max_strikes === 2) {
554
+ } else if (h.strikes.max_strikes === strikeThreshold - 1) {
552
555
  // Warning level 2 (orange) - one more strike = ejectable
553
556
  document.getElementById('health-overall').innerHTML =
554
- `<span class="text-orange-400">Warning - ${h.strikes.validators_near_ejection} validator(s) at 2/3 strikes</span>`;
557
+ `<span class="text-orange-400">Warning - ${h.strikes.validators_near_ejection} validator(s) at ${strikeThreshold - 1}/${strikeThreshold} strikes</span>`;
555
558
  } else {
556
559
  // Warning level 1 (yellow) - has strikes but not critical
557
560
  document.getElementById('health-overall').innerHTML =