csm-dashboard 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csm-dashboard
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Lido CSM Operator Dashboard for tracking validator earnings
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.104
@@ -6,11 +6,11 @@ src/abis/CSModule.json,sha256=T6D6aInBoqVH3ZD6U6p3lrPa3t_ucA9V83IwE80kOuU,1687
6
6
  src/abis/__init__.py,sha256=9HV2hKMGSoNAi8evsjzymTr4V4kowITNsX1-LPu6l98,20
7
7
  src/abis/stETH.json,sha256=ldxbIRrtt8ePVFewJ9Tnz4qUGFmuOXa41GN1t3tnWEg,1106
8
8
  src/cli/__init__.py,sha256=mgHAwomqzAhOHJnlWrWtCguhGzhDWlkCvkzKsfoFsds,35
9
- src/cli/commands.py,sha256=hEbTsj5dTF_K88yUlMWKjRCOuUDYCpugKjQI9vPpMGw,34858
9
+ src/cli/commands.py,sha256=B8-TUAPyQPanTGMd_nom6eo-PhnshrEiJD0NwLkdsbw,34954
10
10
  src/core/__init__.py,sha256=ZDHZojANK1ZFpn5lQROERTo97MYQFAxqA9APvs7ruEQ,57
11
11
  src/core/config.py,sha256=NqkS2zsWyDMDz-q0eE9akHmTE9kZ-snGdTxLn-Dks4E,1532
12
12
  src/core/contracts.py,sha256=8Y72h5uUTaIMLWYdtu5O2Bjw1hTyIHOegaDs0W8r74g,525
13
- src/core/types.py,sha256=BrwLnZ1_yYKh-mVlw9j5UmV5ci-zWmg_Whsn3-xSZKc,7211
13
+ src/core/types.py,sha256=ymHofuF5kkZDQpi-RyO4ZRHGJqz-_fw21lNRY-bnefA,7311
14
14
  src/data/__init__.py,sha256=DItA8aEp8Lbr0uFlJVppMaTtVEEznoA1hkRpH-vfHhk,57
15
15
  src/data/beacon.py,sha256=9oaR7TO2WcP_D3jVTzcd9WE6NbF2WnqQDW_y5M8GsZ0,13511
16
16
  src/data/cache.py,sha256=w1iv4rM-FVgFlaSNYdQA3CfqyhZo9-gqbZwKmzKY0Ps,2134
@@ -18,15 +18,15 @@ src/data/etherscan.py,sha256=UeCucGPd4m39yl13uogY7hCBm46Ge7nZQ96V3eoOmu4,5064
18
18
  src/data/ipfs_logs.py,sha256=gXUTP9dmZ_e7gW6ouotJ1r_72ixq2q_32y2evYQf0DM,10709
19
19
  src/data/known_cids.py,sha256=onwTTNvAv4h0-3LZgLhqMlKzuNH2VhBqQGZ9fm8GyoE,1705
20
20
  src/data/lido_api.py,sha256=477-1vqlMAwLaisaa61T9AxJuSUV6W7NLg1cxvdmheY,4884
21
- src/data/onchain.py,sha256=A8KiNldL_-KStFNDzvzROFqYGgZOBaDj8O0chWfOl8w,16657
21
+ src/data/onchain.py,sha256=3B2dL8PHQL2BkSySUYOq1jwPI3WkUTOFPbiVkECiiMU,16716
22
22
  src/data/rewards_tree.py,sha256=a-MO14b4COjOvy59FPd0jaf1dkgidlqCQKAFDinwRJU,1894
23
- src/data/strikes.py,sha256=9iSW7Xm2W0rqySAJn5IwqCCKf-ef2XazC7tYHgz9REk,7480
23
+ src/data/strikes.py,sha256=b68exKntZpzjiEBftiI3AcvEclHp7vg5lsOiG2a7Kzo,9046
24
24
  src/services/__init__.py,sha256=MC7blFLAMazErCWuyYXvS6sO3uZm1z_RUOtnlIK0kSo,38
25
- src/services/operator_service.py,sha256=Yau_pDylc-tSJmlVypEJVzKRhfXGMCud5Cov2rtk990,29173
25
+ src/services/operator_service.py,sha256=Q_Sc65T06IMHUAP_zgROGkZSbEyFPSL4B4ssVsFEUSg,29562
26
26
  src/web/__init__.py,sha256=iI2c5xxXmzsNxIetm0P2qE3uVsT-ClsMfzn620r5YTU,40
27
- src/web/app.py,sha256=qEIB05J0sKEeZkfHkJwotltsL-d2j1KTDS56cQl2_IU,32129
28
- src/web/routes.py,sha256=sBO7pQBJwbfyyQ61pbQRa6eCugTki6UC6yyZpQW-ne8,9488
29
- csm_dashboard-0.3.0.dist-info/METADATA,sha256=AIHgFHGGEEInmieAAHvJ6DB1vsXTn0Nq0OI1U02cc-w,12552
30
- csm_dashboard-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
- csm_dashboard-0.3.0.dist-info/entry_points.txt,sha256=P1Ul8ALIPBwDlVlXqTPuzJ64xxRpIJsYW8U73Tyjgtg,37
32
- csm_dashboard-0.3.0.dist-info/RECORD,,
27
+ src/web/app.py,sha256=PdO3sVA0j52UXbI7mRHG0VwZ_WVFy6pxJzCHlUIf_1A,32522
28
+ src/web/routes.py,sha256=cswOYAsw9x4tEmGaHBDbkwUvML80M1onmDfRlhtQkGo,9895
29
+ csm_dashboard-0.3.1.dist-info/METADATA,sha256=KgIqd2ixwSgMWwtV2MIJlwdvgh-2WC8oK4KdATEAflQ,12552
30
+ csm_dashboard-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
+ csm_dashboard-0.3.1.dist-info/entry_points.txt,sha256=P1Ul8ALIPBwDlVlXqTPuzJ64xxRpIJsYW8U73Tyjgtg,37
32
+ csm_dashboard-0.3.1.dist-info/RECORD,,
src/cli/commands.py CHANGED
@@ -34,16 +34,16 @@ def format_as_api_json(rewards: OperatorRewards, include_validators: bool = Fals
34
34
  "curve_id": rewards.curve_id,
35
35
  "operator_type": rewards.operator_type,
36
36
  "rewards": {
37
- "current_bond_eth": float(rewards.current_bond_eth),
38
- "required_bond_eth": float(rewards.required_bond_eth),
39
- "excess_bond_eth": float(rewards.excess_bond_eth),
37
+ "current_bond_eth": str(rewards.current_bond_eth),
38
+ "required_bond_eth": str(rewards.required_bond_eth),
39
+ "excess_bond_eth": str(rewards.excess_bond_eth),
40
40
  "cumulative_rewards_shares": rewards.cumulative_rewards_shares,
41
- "cumulative_rewards_eth": float(rewards.cumulative_rewards_eth),
41
+ "cumulative_rewards_eth": str(rewards.cumulative_rewards_eth),
42
42
  "distributed_shares": rewards.distributed_shares,
43
- "distributed_eth": float(rewards.distributed_eth),
43
+ "distributed_eth": str(rewards.distributed_eth),
44
44
  "unclaimed_shares": rewards.unclaimed_shares,
45
- "unclaimed_eth": float(rewards.unclaimed_eth),
46
- "total_claimable_eth": float(rewards.total_claimable_eth),
45
+ "unclaimed_eth": str(rewards.unclaimed_eth),
46
+ "total_claimable_eth": str(rewards.total_claimable_eth),
47
47
  },
48
48
  "validators": {
49
49
  "total": rewards.total_validators,
@@ -122,7 +122,7 @@ def format_as_api_json(rewards: OperatorRewards, include_validators: bool = Fals
122
122
  if rewards.health:
123
123
  result["health"] = {
124
124
  "bond_healthy": rewards.health.bond_healthy,
125
- "bond_deficit_eth": float(rewards.health.bond_deficit_eth),
125
+ "bond_deficit_eth": str(rewards.health.bond_deficit_eth),
126
126
  "stuck_validators_count": rewards.health.stuck_validators_count,
127
127
  "slashed_validators_count": rewards.health.slashed_validators_count,
128
128
  "validators_at_risk_count": rewards.health.validators_at_risk_count,
@@ -576,31 +576,31 @@ def rewards(
576
576
  console.print(history_table)
577
577
  console.print()
578
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")
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
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 "--"
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
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
- )
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
601
 
602
- console.print(withdrawal_table)
603
- console.print()
602
+ console.print(withdrawal_table)
603
+ console.print()
604
604
 
605
605
 
606
606
  @app.command()
@@ -665,11 +665,12 @@ def health(
665
665
  # Fetch validator strikes details
666
666
  validator_strikes = []
667
667
  if rewards.health.strikes.total_validators_with_strikes > 0:
668
- strikes_data = run_async(service.get_operator_strikes(rewards.node_operator_id))
668
+ strikes_data = run_async(service.get_operator_strikes(rewards.node_operator_id, rewards.curve_id))
669
669
  validator_strikes = [
670
670
  {
671
671
  "pubkey": vs.pubkey,
672
672
  "strike_count": vs.strike_count,
673
+ "strike_threshold": vs.strike_threshold,
673
674
  "at_ejection_risk": vs.at_ejection_risk,
674
675
  }
675
676
  for vs in strikes_data
@@ -677,7 +678,7 @@ def health(
677
678
 
678
679
  result["health"] = {
679
680
  "bond_healthy": rewards.health.bond_healthy,
680
- "bond_deficit_eth": float(rewards.health.bond_deficit_eth),
681
+ "bond_deficit_eth": str(rewards.health.bond_deficit_eth),
681
682
  "stuck_validators_count": rewards.health.stuck_validators_count,
682
683
  "slashed_validators_count": rewards.health.slashed_validators_count,
683
684
  "validators_at_risk_count": rewards.health.validators_at_risk_count,
@@ -687,6 +688,7 @@ def health(
687
688
  "validators_near_ejection": rewards.health.strikes.validators_near_ejection,
688
689
  "total_strikes": rewards.health.strikes.total_strikes,
689
690
  "max_strikes": rewards.health.strikes.max_strikes,
691
+ "strike_threshold": rewards.health.strikes.strike_threshold,
690
692
  "validators": validator_strikes,
691
693
  },
692
694
  "has_issues": rewards.health.has_issues,
@@ -783,9 +785,9 @@ def health(
783
785
  if strikes.total_validators_with_strikes > 0:
784
786
  console.print()
785
787
  console.print("[bold]Validator Strikes Detail:[/bold]")
786
- validator_strikes = run_async(service.get_operator_strikes(rewards.node_operator_id))
788
+ validator_strikes = run_async(service.get_operator_strikes(rewards.node_operator_id, rewards.curve_id))
787
789
  for vs in validator_strikes:
788
- strike_display = f"{vs.strike_count}/3"
790
+ strike_display = f"{vs.strike_count}/{vs.strike_threshold}"
789
791
  if vs.at_ejection_risk:
790
792
  console.print(f" {vs.pubkey}: [red bold]{strike_display}[/red bold] (EJECTION RISK!)")
791
793
  elif vs.strike_count > 0:
src/core/types.py CHANGED
@@ -144,10 +144,11 @@ class StrikeSummary(BaseModel):
144
144
  """Summary of strikes for an operator."""
145
145
 
146
146
  total_validators_with_strikes: int = 0
147
- validators_at_risk: int = 0 # Validators with 3+ strikes (ejection eligible)
148
- validators_near_ejection: int = 0 # Validators with 2 strikes (one away from ejection)
147
+ validators_at_risk: int = 0 # Validators at or above strike threshold (ejection eligible)
148
+ validators_near_ejection: int = 0 # Validators one strike away from ejection
149
149
  total_strikes: int = 0
150
150
  max_strikes: int = 0 # Highest strike count on any single validator
151
+ strike_threshold: int = 3 # Strikes required for ejection (3 for Permissionless, 4 for ICS)
151
152
 
152
153
 
153
154
  class HealthStatus(BaseModel):
src/data/onchain.py CHANGED
@@ -339,8 +339,9 @@ class OnChainDataProvider:
339
339
  """
340
340
  Get withdrawal history for an operator's reward address.
341
341
 
342
- Queries stETH Transfer events from CSFeeDistributor to the reward address.
342
+ Queries stETH Transfer events from CSAccounting to the reward address.
343
343
  These represent when the operator claimed their rewards.
344
+ (Note: Claims flow CSFeeDistributor -> CSAccounting -> reward_address)
344
345
 
345
346
  Args:
346
347
  reward_address: The operator's reward address
@@ -353,14 +354,14 @@ class OnChainDataProvider:
353
354
  start_block = 20873000 # CSM deployment block
354
355
 
355
356
  reward_address = Web3.to_checksum_address(reward_address)
356
- csfeedistributor_address = self.settings.csfeedistributor_address
357
+ csaccounting_address = self.settings.csaccounting_address
357
358
 
358
359
  # 1. Try Etherscan API first (most reliable)
359
360
  etherscan = EtherscanProvider()
360
361
  if etherscan.is_available():
361
362
  events = await etherscan.get_transfer_events(
362
363
  token_address=self.settings.steth_address,
363
- from_address=csfeedistributor_address,
364
+ from_address=csaccounting_address,
364
365
  to_address=reward_address,
365
366
  from_block=start_block,
366
367
  )
@@ -370,7 +371,7 @@ class OnChainDataProvider:
370
371
 
371
372
  # 2. Try chunked RPC queries
372
373
  events = await self._query_transfer_events_chunked(
373
- csfeedistributor_address, reward_address, start_block
374
+ csaccounting_address, reward_address, start_block
374
375
  )
375
376
  if events:
376
377
  return await self._enrich_withdrawal_events(events)
src/data/strikes.py CHANGED
@@ -13,6 +13,22 @@ from ..core.config import get_settings
13
13
  from .cache import cached
14
14
 
15
15
 
16
+ # Strike thresholds by operator type (curve_id)
17
+ # Default (Permissionless): 3 strikes till key exit
18
+ # ICS (Identified Community Staker): 4 strikes till key exit
19
+ STRIKE_THRESHOLDS = {
20
+ 0: 3, # Permissionless (Legacy)
21
+ 1: 4, # ICS/Legacy EA
22
+ 2: 3, # Permissionless (current)
23
+ }
24
+ DEFAULT_STRIKE_THRESHOLD = 3
25
+
26
+
27
+ def get_strike_threshold(curve_id: int) -> int:
28
+ """Get the strike threshold for ejection based on operator curve_id."""
29
+ return STRIKE_THRESHOLDS.get(curve_id, DEFAULT_STRIKE_THRESHOLD)
30
+
31
+
16
32
  @dataclass
17
33
  class ValidatorStrikes:
18
34
  """Strike information for a single validator."""
@@ -20,7 +36,8 @@ class ValidatorStrikes:
20
36
  pubkey: str
21
37
  strikes: list[int] # Array of 6 values (0 or 1) representing strikes per frame
22
38
  strike_count: int # Total strikes in the 6-frame window
23
- at_ejection_risk: bool # True if 3+ strikes (eligible for ejection)
39
+ strike_threshold: int # Number of strikes required for ejection (3 or 4)
40
+ at_ejection_risk: bool # True if strike_count >= strike_threshold
24
41
 
25
42
 
26
43
  class StrikesProvider:
@@ -141,12 +158,16 @@ class StrikesProvider:
141
158
  return None
142
159
  return await self._fetch_tree_from_ipfs(cid)
143
160
 
144
- async def get_operator_strikes(self, operator_id: int) -> list[ValidatorStrikes]:
161
+ async def get_operator_strikes(
162
+ self, operator_id: int, curve_id: int | None = None
163
+ ) -> list[ValidatorStrikes]:
145
164
  """
146
165
  Get strikes for all validators belonging to an operator.
147
166
 
148
167
  Args:
149
168
  operator_id: The CSM operator ID
169
+ curve_id: The operator's bond curve ID (determines strike threshold)
170
+ If None, defaults to 3 strikes (permissionless threshold)
150
171
 
151
172
  Returns:
152
173
  List of ValidatorStrikes for validators with any strikes.
@@ -159,6 +180,9 @@ class StrikesProvider:
159
180
  values = tree_data.get("values", [])
160
181
  operator_strikes = []
161
182
 
183
+ # Determine strike threshold based on operator type
184
+ strike_threshold = get_strike_threshold(curve_id) if curve_id is not None else DEFAULT_STRIKE_THRESHOLD
185
+
162
186
  for entry in values:
163
187
  value = entry.get("value", [])
164
188
  if len(value) < 3:
@@ -179,33 +203,42 @@ class StrikesProvider:
179
203
  pubkey=pubkey,
180
204
  strikes=strikes_array if isinstance(strikes_array, list) else [],
181
205
  strike_count=strike_count,
182
- at_ejection_risk=strike_count >= 3,
206
+ strike_threshold=strike_threshold,
207
+ at_ejection_risk=strike_count >= strike_threshold,
183
208
  )
184
209
  )
185
210
 
186
211
  return operator_strikes
187
212
 
188
213
  async def get_operator_strike_summary(
189
- self, operator_id: int
214
+ self, operator_id: int, curve_id: int | None = None
190
215
  ) -> dict[str, int]:
191
216
  """
192
217
  Get a summary of strikes for an operator.
193
218
 
219
+ Args:
220
+ operator_id: The CSM operator ID
221
+ curve_id: The operator's bond curve ID (determines strike threshold)
222
+
194
223
  Returns:
195
224
  Dict with:
196
225
  - total_validators_with_strikes: Count of validators with any strikes
197
- - validators_at_risk: Count of validators with 3+ strikes
226
+ - validators_at_risk: Count of validators at ejection risk (>= threshold)
227
+ - validators_near_ejection: Count one strike away from ejection
198
228
  - total_strikes: Sum of all strikes across all validators
199
229
  - max_strikes: Highest strike count on any single validator
230
+ - strike_threshold: The ejection threshold for this operator type
200
231
  """
201
- strikes = await self.get_operator_strikes(operator_id)
232
+ strikes = await self.get_operator_strikes(operator_id, curve_id)
233
+ strike_threshold = get_strike_threshold(curve_id) if curve_id is not None else DEFAULT_STRIKE_THRESHOLD
202
234
 
203
235
  return {
204
236
  "total_validators_with_strikes": len(strikes),
205
237
  "validators_at_risk": sum(1 for s in strikes if s.at_ejection_risk),
206
- "validators_near_ejection": sum(1 for s in strikes if s.strike_count == 2),
238
+ "validators_near_ejection": sum(1 for s in strikes if s.strike_count == strike_threshold - 1),
207
239
  "total_strikes": sum(s.strike_count for s in strikes),
208
240
  "max_strikes": max((s.strike_count for s in strikes), default=0),
241
+ "strike_threshold": strike_threshold,
209
242
  }
210
243
 
211
244
  def clear_cache(self) -> None:
@@ -127,6 +127,7 @@ class OperatorService:
127
127
  bond=bond,
128
128
  stuck_validators_count=operator.stuck_validators_count,
129
129
  validator_details=validator_details,
130
+ curve_id=curve_id,
130
131
  )
131
132
 
132
133
  # Step 12: Fetch withdrawal history if requested
@@ -503,6 +504,7 @@ class OperatorService:
503
504
  bond: BondSummary,
504
505
  stuck_validators_count: int,
505
506
  validator_details: list[ValidatorInfo],
507
+ curve_id: int | None = None,
506
508
  ) -> HealthStatus:
507
509
  """Calculate health status for an operator.
508
510
 
@@ -516,16 +518,17 @@ class OperatorService:
516
518
  slashed_count = count_slashed_validators(validator_details)
517
519
  at_risk_count = count_at_risk_validators(validator_details)
518
520
 
519
- # Get strikes data
521
+ # Get strikes data (pass curve_id for operator-specific thresholds)
520
522
  strike_summary = StrikeSummary()
521
523
  try:
522
- summary = await self.strikes.get_operator_strike_summary(operator_id)
524
+ summary = await self.strikes.get_operator_strike_summary(operator_id, curve_id)
523
525
  strike_summary = StrikeSummary(
524
526
  total_validators_with_strikes=summary.get("total_validators_with_strikes", 0),
525
527
  validators_at_risk=summary.get("validators_at_risk", 0),
526
528
  validators_near_ejection=summary.get("validators_near_ejection", 0),
527
529
  total_strikes=summary.get("total_strikes", 0),
528
530
  max_strikes=summary.get("max_strikes", 0),
531
+ strike_threshold=summary.get("strike_threshold", 3),
529
532
  )
530
533
  except Exception:
531
534
  # If strikes fetch fails, continue with empty summary
@@ -540,9 +543,14 @@ class OperatorService:
540
543
  strikes=strike_summary,
541
544
  )
542
545
 
543
- async def get_operator_strikes(self, operator_id: int):
544
- """Get detailed strikes for an operator's validators."""
545
- 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)
546
554
 
547
555
  async def get_recent_frame_dates(self, count: int = 6) -> list[dict]:
548
556
  """Get date ranges for the most recent N distribution frames.
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 =
src/web/routes.py CHANGED
@@ -46,16 +46,16 @@ async def get_operator(
46
46
  "curve_id": rewards.curve_id,
47
47
  "operator_type": rewards.operator_type,
48
48
  "rewards": {
49
- "current_bond_eth": float(rewards.current_bond_eth),
50
- "required_bond_eth": float(rewards.required_bond_eth),
51
- "excess_bond_eth": float(rewards.excess_bond_eth),
49
+ "current_bond_eth": str(rewards.current_bond_eth),
50
+ "required_bond_eth": str(rewards.required_bond_eth),
51
+ "excess_bond_eth": str(rewards.excess_bond_eth),
52
52
  "cumulative_rewards_shares": rewards.cumulative_rewards_shares,
53
- "cumulative_rewards_eth": float(rewards.cumulative_rewards_eth),
53
+ "cumulative_rewards_eth": str(rewards.cumulative_rewards_eth),
54
54
  "distributed_shares": rewards.distributed_shares,
55
- "distributed_eth": float(rewards.distributed_eth),
55
+ "distributed_eth": str(rewards.distributed_eth),
56
56
  "unclaimed_shares": rewards.unclaimed_shares,
57
- "unclaimed_eth": float(rewards.unclaimed_eth),
58
- "total_claimable_eth": float(rewards.total_claimable_eth),
57
+ "unclaimed_eth": str(rewards.unclaimed_eth),
58
+ "total_claimable_eth": str(rewards.total_claimable_eth),
59
59
  },
60
60
  "validators": {
61
61
  "total": rewards.total_validators,
@@ -153,7 +153,7 @@ async def get_operator(
153
153
  if rewards.health:
154
154
  result["health"] = {
155
155
  "bond_healthy": rewards.health.bond_healthy,
156
- "bond_deficit_eth": float(rewards.health.bond_deficit_eth),
156
+ "bond_deficit_eth": str(rewards.health.bond_deficit_eth),
157
157
  "stuck_validators_count": rewards.health.stuck_validators_count,
158
158
  "slashed_validators_count": rewards.health.slashed_validators_count,
159
159
  "validators_at_risk_count": rewards.health.validators_at_risk_count,
@@ -163,6 +163,7 @@ async def get_operator(
163
163
  "validators_near_ejection": rewards.health.strikes.validators_near_ejection,
164
164
  "total_strikes": rewards.health.strikes.total_strikes,
165
165
  "max_strikes": rewards.health.strikes.max_strikes,
166
+ "strike_threshold": rewards.health.strikes.strike_threshold,
166
167
  },
167
168
  "has_issues": rewards.health.has_issues,
168
169
  }
@@ -193,18 +194,25 @@ async def get_operator_strikes(identifier: str):
193
194
  else:
194
195
  raise HTTPException(status_code=400, detail="Invalid identifier format")
195
196
 
196
- strikes = await service.get_operator_strikes(operator_id)
197
+ # Get curve_id to determine strike threshold
198
+ curve_id = await service.onchain.get_bond_curve_id(operator_id)
199
+ strikes = await service.get_operator_strikes(operator_id, curve_id)
197
200
 
198
201
  # Fetch frame dates for tooltip display
199
202
  frame_dates = await service.get_recent_frame_dates(6)
200
203
 
204
+ # Get the threshold for this operator type
205
+ strike_threshold = strikes[0].strike_threshold if strikes else 3
206
+
201
207
  return {
202
208
  "operator_id": operator_id,
209
+ "strike_threshold": strike_threshold,
203
210
  "frame_dates": frame_dates,
204
211
  "validators": [
205
212
  {
206
213
  "pubkey": s.pubkey,
207
214
  "strike_count": s.strike_count,
215
+ "strike_threshold": s.strike_threshold,
208
216
  "strikes": s.strikes,
209
217
  "at_ejection_risk": s.at_ejection_risk,
210
218
  }