csm-dashboard 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (48) hide show
  1. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/PKG-INFO +1 -1
  2. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/pyproject.toml +1 -1
  3. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/cli/commands.py +36 -34
  4. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/types.py +3 -2
  5. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/onchain.py +5 -4
  6. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/strikes.py +40 -7
  7. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/services/operator_service.py +13 -5
  8. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/web/app.py +10 -7
  9. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/web/routes.py +17 -9
  10. csm_dashboard-0.3.1/tests/conftest.py +108 -0
  11. csm_dashboard-0.3.1/tests/unit/test_cache.py +162 -0
  12. csm_dashboard-0.3.1/tests/unit/test_config.py +91 -0
  13. csm_dashboard-0.3.1/tests/unit/test_strikes.py +115 -0
  14. csm_dashboard-0.3.1/tests/unit/test_types.py +207 -0
  15. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.dockerignore +0 -0
  16. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.env.example +0 -0
  17. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.github/workflows/docker-publish.yaml +0 -0
  18. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.github/workflows/release.yaml +0 -0
  19. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.gitignore +0 -0
  20. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/Dockerfile +0 -0
  21. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/README.md +0 -0
  22. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/docker-compose.yml +0 -0
  23. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/img/csm-dash-cli.png +0 -0
  24. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/img/csm-dash-web.png +0 -0
  25. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/img/logo.png +0 -0
  26. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/requirements.txt +0 -0
  27. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/__init__.py +0 -0
  28. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/CSAccounting.json +0 -0
  29. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/CSFeeDistributor.json +0 -0
  30. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/CSModule.json +0 -0
  31. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/__init__.py +0 -0
  32. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/stETH.json +0 -0
  33. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/cli/__init__.py +0 -0
  34. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/__init__.py +0 -0
  35. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/config.py +0 -0
  36. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/contracts.py +0 -0
  37. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/__init__.py +0 -0
  38. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/beacon.py +0 -0
  39. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/cache.py +0 -0
  40. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/etherscan.py +0 -0
  41. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/ipfs_logs.py +0 -0
  42. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/known_cids.py +0 -0
  43. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/lido_api.py +0 -0
  44. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/rewards_tree.py +0 -0
  45. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/main.py +0 -0
  46. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/services/__init__.py +0 -0
  47. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/web/__init__.py +0 -0
  48. {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/tests/__init__.py +0 -0
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csm-dashboard"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Lido CSM Operator Dashboard for tracking validator earnings"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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:
@@ -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):
@@ -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)
@@ -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.
@@ -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 =
@@ -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
  }
@@ -0,0 +1,108 @@
1
+ """Shared pytest fixtures for CSM Dashboard tests."""
2
+
3
+ import pytest
4
+ from decimal import Decimal
5
+ from datetime import datetime, timedelta
6
+
7
+ from src.core.types import (
8
+ APYMetrics,
9
+ BondSummary,
10
+ DistributionFrame,
11
+ HealthStatus,
12
+ OperatorRewards,
13
+ StrikeSummary,
14
+ WithdrawalEvent,
15
+ )
16
+
17
+
18
+ @pytest.fixture
19
+ def sample_bond_summary():
20
+ """Sample bond summary data."""
21
+ return BondSummary(
22
+ current_bond_wei=26269317414398397106,
23
+ required_bond_wei=26200000000000000000,
24
+ current_bond_eth=Decimal("26.269317414398397106"),
25
+ required_bond_eth=Decimal("26.2"),
26
+ excess_bond_eth=Decimal("0.069317414398397106"),
27
+ )
28
+
29
+
30
+ @pytest.fixture
31
+ def sample_strike_summary():
32
+ """Sample strike summary data."""
33
+ return StrikeSummary(
34
+ total_validators_with_strikes=2,
35
+ validators_at_risk=1,
36
+ validators_near_ejection=1,
37
+ total_strikes=5,
38
+ max_strikes=3,
39
+ strike_threshold=3,
40
+ )
41
+
42
+
43
+ @pytest.fixture
44
+ def sample_health_status(sample_strike_summary):
45
+ """Sample health status data."""
46
+ return HealthStatus(
47
+ bond_healthy=True,
48
+ bond_deficit_eth=Decimal("0"),
49
+ stuck_validators_count=0,
50
+ slashed_validators_count=0,
51
+ validators_at_risk_count=0,
52
+ strikes=sample_strike_summary,
53
+ )
54
+
55
+
56
+ @pytest.fixture
57
+ def sample_apy_metrics():
58
+ """Sample APY metrics data."""
59
+ return APYMetrics(
60
+ current_distribution_eth=0.5,
61
+ current_distribution_apy=2.77,
62
+ current_bond_eth=0.3,
63
+ current_bond_apr=2.56,
64
+ previous_distribution_eth=0.45,
65
+ previous_distribution_apy=2.65,
66
+ lifetime_reward_apy=2.80,
67
+ lifetime_bond_apy=2.60,
68
+ lifetime_net_apy=5.40,
69
+ )
70
+
71
+
72
+ @pytest.fixture
73
+ def sample_operator_rewards(sample_bond_summary, sample_health_status, sample_apy_metrics):
74
+ """Sample operator rewards data."""
75
+ return OperatorRewards(
76
+ node_operator_id=333,
77
+ manager_address="0x6ac683C503CF210CCF88193ec7ebDe2c993f63a4",
78
+ reward_address="0x55915Cf2115c4D6e9085e94c8dAD710cabefef31",
79
+ curve_id=2,
80
+ operator_type="Permissionless",
81
+ current_bond_eth=sample_bond_summary.current_bond_eth,
82
+ required_bond_eth=sample_bond_summary.required_bond_eth,
83
+ excess_bond_eth=sample_bond_summary.excess_bond_eth,
84
+ cumulative_rewards_shares=1234567890,
85
+ cumulative_rewards_eth=Decimal("10.96"),
86
+ distributed_shares=1000000000,
87
+ distributed_eth=Decimal("9.61"),
88
+ unclaimed_shares=234567890,
89
+ unclaimed_eth=Decimal("1.35"),
90
+ total_claimable_eth=Decimal("1.419317414398397106"),
91
+ total_validators=500,
92
+ active_validators=500,
93
+ exited_validators=0,
94
+ health=sample_health_status,
95
+ apy=sample_apy_metrics,
96
+ )
97
+
98
+
99
+ @pytest.fixture
100
+ def sample_withdrawal_event():
101
+ """Sample withdrawal event data."""
102
+ return WithdrawalEvent(
103
+ block_number=21278373,
104
+ timestamp="2024-12-15T10:30:00",
105
+ shares=126100000000000000,
106
+ eth_value=0.1261,
107
+ tx_hash="0x59efb01ebbc20103d4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3",
108
+ )
@@ -0,0 +1,162 @@
1
+ """Tests for the cache module."""
2
+
3
+ import pytest
4
+ from datetime import datetime, timedelta
5
+ from unittest.mock import patch
6
+
7
+ from src.data.cache import SimpleCache, cached, get_cache
8
+
9
+
10
+ class TestSimpleCache:
11
+ """Tests for the SimpleCache class."""
12
+
13
+ def test_cache_set_and_get(self):
14
+ """Test basic set and get operations."""
15
+ cache = SimpleCache(default_ttl=300)
16
+ cache.set("key1", "value1")
17
+ assert cache.get("key1") == "value1"
18
+
19
+ def test_cache_returns_none_for_missing_key(self):
20
+ """Test that missing keys return None."""
21
+ cache = SimpleCache(default_ttl=300)
22
+ assert cache.get("nonexistent") is None
23
+
24
+ def test_cache_expiry(self):
25
+ """Test that expired entries are not returned."""
26
+ cache = SimpleCache(default_ttl=300)
27
+ cache.set("key1", "value1", ttl=1)
28
+
29
+ # Immediately should work
30
+ assert cache.get("key1") == "value1"
31
+
32
+ # Mock time to be past expiry
33
+ with patch("src.data.cache.datetime") as mock_datetime:
34
+ mock_datetime.now.return_value = datetime.now() + timedelta(seconds=2)
35
+ assert cache.get("key1") is None
36
+
37
+ def test_cache_custom_ttl(self):
38
+ """Test setting custom TTL per entry."""
39
+ cache = SimpleCache(default_ttl=300)
40
+ cache.set("key1", "value1", ttl=600)
41
+
42
+ # Mock time to be 400 seconds later (past default TTL but within custom TTL)
43
+ with patch("src.data.cache.datetime") as mock_datetime:
44
+ mock_datetime.now.return_value = datetime.now() + timedelta(seconds=400)
45
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
46
+ # Note: This test is tricky due to how timedelta works with mocked datetime
47
+ # The cache stores expiry at set time, so checking is against stored expiry
48
+
49
+ def test_cache_clear(self):
50
+ """Test clearing all cached entries."""
51
+ cache = SimpleCache(default_ttl=300)
52
+ cache.set("key1", "value1")
53
+ cache.set("key2", "value2")
54
+
55
+ assert cache.get("key1") == "value1"
56
+ assert cache.get("key2") == "value2"
57
+
58
+ cache.clear()
59
+
60
+ assert cache.get("key1") is None
61
+ assert cache.get("key2") is None
62
+
63
+ def test_cache_overwrite(self):
64
+ """Test that setting the same key overwrites the value."""
65
+ cache = SimpleCache(default_ttl=300)
66
+ cache.set("key1", "value1")
67
+ cache.set("key1", "value2")
68
+ assert cache.get("key1") == "value2"
69
+
70
+ def test_cache_stores_different_types(self):
71
+ """Test that cache can store various Python types."""
72
+ cache = SimpleCache(default_ttl=300)
73
+
74
+ cache.set("str", "string value")
75
+ cache.set("int", 42)
76
+ cache.set("float", 3.14)
77
+ cache.set("list", [1, 2, 3])
78
+ cache.set("dict", {"a": 1, "b": 2})
79
+ cache.set("none", None)
80
+
81
+ assert cache.get("str") == "string value"
82
+ assert cache.get("int") == 42
83
+ assert cache.get("float") == 3.14
84
+ assert cache.get("list") == [1, 2, 3]
85
+ assert cache.get("dict") == {"a": 1, "b": 2}
86
+ # Note: None is a valid cached value, but get() returns None for missing keys too
87
+
88
+
89
+ class TestCachedDecorator:
90
+ """Tests for the @cached decorator."""
91
+
92
+ @pytest.fixture(autouse=True)
93
+ def clear_cache(self):
94
+ """Clear the global cache before each test."""
95
+ get_cache().clear()
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_cached_decorator_caches_result(self):
99
+ """Test that the decorator caches function results."""
100
+ call_count = 0
101
+
102
+ @cached(ttl=300)
103
+ async def my_function(x):
104
+ nonlocal call_count
105
+ call_count += 1
106
+ return x * 2
107
+
108
+ # First call should execute the function
109
+ result1 = await my_function(5)
110
+ assert result1 == 10
111
+ assert call_count == 1
112
+
113
+ # Second call should return cached result
114
+ result2 = await my_function(5)
115
+ assert result2 == 10
116
+ assert call_count == 1 # Function not called again
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_cached_decorator_different_args(self):
120
+ """Test that different arguments result in different cache keys."""
121
+ call_count = 0
122
+
123
+ @cached(ttl=300)
124
+ async def my_function(x):
125
+ nonlocal call_count
126
+ call_count += 1
127
+ return x * 2
128
+
129
+ result1 = await my_function(5)
130
+ result2 = await my_function(10)
131
+
132
+ assert result1 == 10
133
+ assert result2 == 20
134
+ assert call_count == 2 # Both should call the function
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_cached_decorator_with_kwargs(self):
138
+ """Test that kwargs are included in cache key."""
139
+ call_count = 0
140
+
141
+ @cached(ttl=300)
142
+ async def my_function(x, multiplier=2):
143
+ nonlocal call_count
144
+ call_count += 1
145
+ return x * multiplier
146
+
147
+ result1 = await my_function(5, multiplier=2)
148
+ result2 = await my_function(5, multiplier=3)
149
+
150
+ assert result1 == 10
151
+ assert result2 == 15
152
+ assert call_count == 2 # Different kwargs = different cache key
153
+
154
+
155
+ class TestGetCache:
156
+ """Tests for the get_cache function."""
157
+
158
+ def test_get_cache_returns_singleton(self):
159
+ """Test that get_cache returns the same instance."""
160
+ cache1 = get_cache()
161
+ cache2 = get_cache()
162
+ assert cache1 is cache2
@@ -0,0 +1,91 @@
1
+ """Tests for the config module."""
2
+
3
+ import os
4
+ import pytest
5
+ from unittest.mock import patch
6
+
7
+ from src.core.config import Settings, get_settings
8
+
9
+
10
+ class TestSettings:
11
+ """Tests for the Settings class."""
12
+
13
+ def test_default_values(self):
14
+ """Test that settings have expected structure and types.
15
+
16
+ Note: Actual values may differ if a .env file is present.
17
+ """
18
+ settings = Settings()
19
+
20
+ # Test that settings exist and have correct types
21
+ assert isinstance(settings.eth_rpc_url, str)
22
+ assert settings.eth_rpc_url.startswith("https://")
23
+ assert isinstance(settings.beacon_api_url, str)
24
+ assert settings.beacon_api_key is None or isinstance(settings.beacon_api_key, str)
25
+ assert settings.etherscan_api_key is None or isinstance(settings.etherscan_api_key, str)
26
+ assert settings.thegraph_api_key is None or isinstance(settings.thegraph_api_key, str)
27
+ assert isinstance(settings.cache_ttl_seconds, int)
28
+ assert settings.cache_ttl_seconds > 0
29
+
30
+ def test_contract_addresses(self):
31
+ """Test that contract addresses are set correctly."""
32
+ settings = Settings()
33
+
34
+ assert settings.csmodule_address == "0xdA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F"
35
+ assert settings.csaccounting_address == "0x4d72BFF1BeaC69925F8Bd12526a39BAAb069e5Da"
36
+ assert settings.csfeedistributor_address == "0xD99CC66fEC647E68294C6477B40fC7E0F6F618D0"
37
+ assert settings.csstrikes_address == "0xaa328816027F2D32B9F56d190BC9Fa4A5C07637f"
38
+ assert settings.steth_address == "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"
39
+
40
+ def test_environment_variable_override(self):
41
+ """Test that environment variables override defaults."""
42
+ custom_rpc = "https://custom.rpc.example.com"
43
+ custom_ttl = "600"
44
+
45
+ with patch.dict(
46
+ os.environ,
47
+ {
48
+ "ETH_RPC_URL": custom_rpc,
49
+ "CACHE_TTL_SECONDS": custom_ttl,
50
+ },
51
+ ):
52
+ settings = Settings()
53
+
54
+ assert settings.eth_rpc_url == custom_rpc
55
+ assert settings.cache_ttl_seconds == 600
56
+
57
+ def test_optional_api_keys(self):
58
+ """Test that optional API keys can be set."""
59
+ with patch.dict(
60
+ os.environ,
61
+ {
62
+ "BEACON_API_KEY": "beacon_key_123",
63
+ "ETHERSCAN_API_KEY": "etherscan_key_456",
64
+ "THEGRAPH_API_KEY": "graph_key_789",
65
+ },
66
+ ):
67
+ settings = Settings()
68
+
69
+ assert settings.beacon_api_key == "beacon_key_123"
70
+ assert settings.etherscan_api_key == "etherscan_key_456"
71
+ assert settings.thegraph_api_key == "graph_key_789"
72
+
73
+
74
+ class TestGetSettings:
75
+ """Tests for the get_settings function."""
76
+
77
+ def test_get_settings_returns_settings_instance(self):
78
+ """Test that get_settings returns a Settings instance."""
79
+ settings = get_settings()
80
+ assert isinstance(settings, Settings)
81
+
82
+ def test_get_settings_is_cached(self):
83
+ """Test that get_settings returns the same cached instance."""
84
+ # Clear the cache first
85
+ get_settings.cache_clear()
86
+
87
+ settings1 = get_settings()
88
+ settings2 = get_settings()
89
+
90
+ # Should be the same instance (cached)
91
+ assert settings1 is settings2
@@ -0,0 +1,115 @@
1
+ """Tests for the strikes module."""
2
+
3
+ import pytest
4
+
5
+ from src.data.strikes import (
6
+ DEFAULT_STRIKE_THRESHOLD,
7
+ STRIKE_THRESHOLDS,
8
+ ValidatorStrikes,
9
+ get_strike_threshold,
10
+ )
11
+
12
+
13
+ class TestStrikeThresholds:
14
+ """Tests for strike threshold logic."""
15
+
16
+ def test_strike_thresholds_mapping(self):
17
+ """Test the STRIKE_THRESHOLDS mapping is correct."""
18
+ assert STRIKE_THRESHOLDS[0] == 3 # Permissionless (Legacy)
19
+ assert STRIKE_THRESHOLDS[1] == 4 # ICS/Legacy EA
20
+ assert STRIKE_THRESHOLDS[2] == 3 # Permissionless (current)
21
+
22
+ def test_default_strike_threshold(self):
23
+ """Test the default strike threshold is 3."""
24
+ assert DEFAULT_STRIKE_THRESHOLD == 3
25
+
26
+ def test_get_strike_threshold_permissionless_legacy(self):
27
+ """Test threshold for curve 0 (Permissionless Legacy)."""
28
+ assert get_strike_threshold(0) == 3
29
+
30
+ def test_get_strike_threshold_ics(self):
31
+ """Test threshold for curve 1 (ICS/Legacy EA)."""
32
+ assert get_strike_threshold(1) == 4
33
+
34
+ def test_get_strike_threshold_permissionless(self):
35
+ """Test threshold for curve 2 (Permissionless)."""
36
+ assert get_strike_threshold(2) == 3
37
+
38
+ def test_get_strike_threshold_unknown_curve(self):
39
+ """Test threshold for unknown curve_id defaults to 3."""
40
+ assert get_strike_threshold(99) == 3
41
+ assert get_strike_threshold(100) == 3
42
+ assert get_strike_threshold(-1) == 3
43
+
44
+
45
+ class TestValidatorStrikes:
46
+ """Tests for ValidatorStrikes dataclass."""
47
+
48
+ def test_validator_strikes_creation(self):
49
+ """Test creating a ValidatorStrikes instance."""
50
+ vs = ValidatorStrikes(
51
+ pubkey="0x12345...",
52
+ strikes=[1, 0, 1, 0, 0, 1],
53
+ strike_count=3,
54
+ strike_threshold=3,
55
+ at_ejection_risk=True,
56
+ )
57
+
58
+ assert vs.pubkey == "0x12345..."
59
+ assert vs.strike_count == 3
60
+ assert vs.strike_threshold == 3
61
+ assert vs.at_ejection_risk is True
62
+
63
+ def test_validator_strikes_not_at_risk(self):
64
+ """Test validator with 2 strikes (not at risk for permissionless)."""
65
+ vs = ValidatorStrikes(
66
+ pubkey="0xabc...",
67
+ strikes=[1, 0, 1, 0, 0, 0],
68
+ strike_count=2,
69
+ strike_threshold=3,
70
+ at_ejection_risk=False,
71
+ )
72
+
73
+ assert vs.strike_count == 2
74
+ assert vs.at_ejection_risk is False
75
+
76
+ def test_validator_strikes_ics_not_at_risk(self):
77
+ """Test ICS validator with 3 strikes (not at risk since threshold is 4)."""
78
+ vs = ValidatorStrikes(
79
+ pubkey="0xdef...",
80
+ strikes=[1, 1, 1, 0, 0, 0],
81
+ strike_count=3,
82
+ strike_threshold=4, # ICS threshold
83
+ at_ejection_risk=False,
84
+ )
85
+
86
+ assert vs.strike_count == 3
87
+ assert vs.strike_threshold == 4
88
+ assert vs.at_ejection_risk is False # Not at risk because threshold is 4
89
+
90
+ def test_validator_strikes_ics_at_risk(self):
91
+ """Test ICS validator with 4 strikes (at risk)."""
92
+ vs = ValidatorStrikes(
93
+ pubkey="0xghi...",
94
+ strikes=[1, 1, 1, 1, 0, 0],
95
+ strike_count=4,
96
+ strike_threshold=4, # ICS threshold
97
+ at_ejection_risk=True,
98
+ )
99
+
100
+ assert vs.strike_count == 4
101
+ assert vs.at_ejection_risk is True
102
+
103
+ def test_validator_strikes_empty_array(self):
104
+ """Test validator with no strikes."""
105
+ vs = ValidatorStrikes(
106
+ pubkey="0xjkl...",
107
+ strikes=[0, 0, 0, 0, 0, 0],
108
+ strike_count=0,
109
+ strike_threshold=3,
110
+ at_ejection_risk=False,
111
+ )
112
+
113
+ assert vs.strike_count == 0
114
+ assert vs.at_ejection_risk is False
115
+ assert sum(vs.strikes) == 0
@@ -0,0 +1,207 @@
1
+ """Tests for the core types module."""
2
+
3
+ import pytest
4
+ from decimal import Decimal
5
+ from pydantic import ValidationError
6
+
7
+ from src.core.types import (
8
+ APYMetrics,
9
+ BondSummary,
10
+ DistributionFrame,
11
+ HealthStatus,
12
+ OperatorRewards,
13
+ StrikeSummary,
14
+ WithdrawalEvent,
15
+ )
16
+
17
+
18
+ class TestBondSummary:
19
+ """Tests for the BondSummary model."""
20
+
21
+ def test_bond_summary_creation(self, sample_bond_summary):
22
+ """Test creating a BondSummary instance."""
23
+ assert sample_bond_summary.current_bond_wei == 26269317414398397106
24
+ assert sample_bond_summary.current_bond_eth == Decimal("26.269317414398397106")
25
+ assert sample_bond_summary.excess_bond_eth == Decimal("0.069317414398397106")
26
+
27
+ def test_bond_summary_decimal_precision(self):
28
+ """Test that Decimal values maintain full precision."""
29
+ bond = BondSummary(
30
+ current_bond_wei=26269317414398397106,
31
+ required_bond_wei=26200000000000000000,
32
+ current_bond_eth=Decimal("26.269317414398397106"),
33
+ required_bond_eth=Decimal("26.200000000000000000"),
34
+ excess_bond_eth=Decimal("0.069317414398397106"),
35
+ )
36
+
37
+ # Verify full precision is maintained
38
+ assert str(bond.current_bond_eth) == "26.269317414398397106"
39
+ assert str(bond.excess_bond_eth) == "0.069317414398397106"
40
+
41
+
42
+ class TestStrikeSummary:
43
+ """Tests for the StrikeSummary model."""
44
+
45
+ def test_strike_summary_defaults(self):
46
+ """Test that StrikeSummary has sensible defaults."""
47
+ summary = StrikeSummary()
48
+
49
+ assert summary.total_validators_with_strikes == 0
50
+ assert summary.validators_at_risk == 0
51
+ assert summary.validators_near_ejection == 0
52
+ assert summary.total_strikes == 0
53
+ assert summary.max_strikes == 0
54
+ assert summary.strike_threshold == 3 # Default threshold
55
+
56
+ def test_strike_summary_with_ics_threshold(self):
57
+ """Test StrikeSummary with ICS threshold (4 strikes)."""
58
+ summary = StrikeSummary(
59
+ total_validators_with_strikes=5,
60
+ validators_at_risk=2,
61
+ validators_near_ejection=2,
62
+ total_strikes=15,
63
+ max_strikes=4,
64
+ strike_threshold=4, # ICS threshold
65
+ )
66
+
67
+ assert summary.strike_threshold == 4
68
+ assert summary.validators_at_risk == 2
69
+
70
+ def test_strike_summary_with_permissionless_threshold(self):
71
+ """Test StrikeSummary with Permissionless threshold (3 strikes)."""
72
+ summary = StrikeSummary(
73
+ total_validators_with_strikes=5,
74
+ validators_at_risk=3,
75
+ validators_near_ejection=1,
76
+ total_strikes=12,
77
+ max_strikes=3,
78
+ strike_threshold=3, # Permissionless threshold
79
+ )
80
+
81
+ assert summary.strike_threshold == 3
82
+ assert summary.validators_at_risk == 3
83
+
84
+
85
+ class TestHealthStatus:
86
+ """Tests for the HealthStatus model."""
87
+
88
+ def test_health_status_defaults(self):
89
+ """Test that HealthStatus has sensible defaults."""
90
+ health = HealthStatus()
91
+
92
+ assert health.bond_healthy is True
93
+ assert health.bond_deficit_eth == Decimal(0)
94
+ assert health.stuck_validators_count == 0
95
+ assert health.slashed_validators_count == 0
96
+ assert health.validators_at_risk_count == 0
97
+ assert health.strikes.strike_threshold == 3
98
+
99
+ def test_has_issues_property_no_issues(self):
100
+ """Test has_issues returns False when no issues."""
101
+ health = HealthStatus()
102
+ assert health.has_issues is False
103
+
104
+ def test_has_issues_property_with_bond_deficit(self):
105
+ """Test has_issues returns True when bond is unhealthy."""
106
+ health = HealthStatus(
107
+ bond_healthy=False,
108
+ bond_deficit_eth=Decimal("1.5"),
109
+ )
110
+ assert health.has_issues is True
111
+
112
+ def test_has_issues_property_with_slashed(self):
113
+ """Test has_issues returns True when validators are slashed."""
114
+ health = HealthStatus(slashed_validators_count=1)
115
+ assert health.has_issues is True
116
+
117
+ def test_has_issues_property_with_strikes(self):
118
+ """Test has_issues returns True when validators have strikes."""
119
+ health = HealthStatus(
120
+ strikes=StrikeSummary(total_validators_with_strikes=1, total_strikes=2)
121
+ )
122
+ assert health.has_issues is True
123
+
124
+
125
+ class TestAPYMetrics:
126
+ """Tests for the APYMetrics model."""
127
+
128
+ def test_apy_metrics_defaults(self):
129
+ """Test that APYMetrics fields are optional with None defaults."""
130
+ apy = APYMetrics()
131
+
132
+ assert apy.current_distribution_eth is None
133
+ assert apy.current_distribution_apy is None
134
+ assert apy.lifetime_reward_apy is None
135
+ assert apy.frames is None
136
+
137
+ def test_apy_metrics_with_values(self, sample_apy_metrics):
138
+ """Test APYMetrics with actual values."""
139
+ assert sample_apy_metrics.current_distribution_apy == 2.77
140
+ assert sample_apy_metrics.lifetime_net_apy == 5.40
141
+
142
+
143
+ class TestDistributionFrame:
144
+ """Tests for the DistributionFrame model."""
145
+
146
+ def test_distribution_frame_creation(self):
147
+ """Test creating a DistributionFrame instance."""
148
+ frame = DistributionFrame(
149
+ frame_number=1,
150
+ start_date="2025-03-14T00:00:00",
151
+ end_date="2025-04-11T00:00:00",
152
+ rewards_eth=1.2345,
153
+ rewards_shares=1234500000000000000,
154
+ duration_days=28.0,
155
+ validator_count=500,
156
+ apy=2.85,
157
+ )
158
+
159
+ assert frame.frame_number == 1
160
+ assert frame.rewards_eth == 1.2345
161
+ assert frame.validator_count == 500
162
+ assert frame.apy == 2.85
163
+
164
+
165
+ class TestWithdrawalEvent:
166
+ """Tests for the WithdrawalEvent model."""
167
+
168
+ def test_withdrawal_event_creation(self, sample_withdrawal_event):
169
+ """Test creating a WithdrawalEvent instance."""
170
+ assert sample_withdrawal_event.block_number == 21278373
171
+ assert sample_withdrawal_event.eth_value == 0.1261
172
+ assert sample_withdrawal_event.tx_hash.startswith("0x")
173
+
174
+ def test_withdrawal_event_required_fields(self):
175
+ """Test that all fields are required for WithdrawalEvent."""
176
+ with pytest.raises(ValidationError):
177
+ WithdrawalEvent() # Missing all required fields
178
+
179
+
180
+ class TestOperatorRewards:
181
+ """Tests for the OperatorRewards model."""
182
+
183
+ def test_operator_rewards_creation(self, sample_operator_rewards):
184
+ """Test creating an OperatorRewards instance."""
185
+ assert sample_operator_rewards.node_operator_id == 333
186
+ assert sample_operator_rewards.operator_type == "Permissionless"
187
+ assert sample_operator_rewards.curve_id == 2
188
+ assert sample_operator_rewards.total_validators == 500
189
+
190
+ def test_operator_rewards_decimal_precision(self, sample_operator_rewards):
191
+ """Test that Decimal fields maintain full precision."""
192
+ # Convert to string to verify full precision
193
+ assert "269317414398397106" in str(sample_operator_rewards.current_bond_eth)
194
+ assert str(sample_operator_rewards.unclaimed_eth) == "1.35"
195
+
196
+ def test_operator_rewards_with_withdrawals(self, sample_operator_rewards, sample_withdrawal_event):
197
+ """Test OperatorRewards with withdrawal history."""
198
+ sample_operator_rewards.withdrawals = [sample_withdrawal_event]
199
+
200
+ assert len(sample_operator_rewards.withdrawals) == 1
201
+ assert sample_operator_rewards.withdrawals[0].eth_value == 0.1261
202
+
203
+ def test_operator_rewards_health_status(self, sample_operator_rewards):
204
+ """Test that health status is properly linked."""
205
+ assert sample_operator_rewards.health is not None
206
+ assert sample_operator_rewards.health.bond_healthy is True
207
+ assert sample_operator_rewards.health.strikes.strike_threshold == 3
File without changes
File without changes
File without changes
File without changes