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.
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/PKG-INFO +1 -1
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/pyproject.toml +1 -1
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/cli/commands.py +36 -34
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/types.py +3 -2
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/onchain.py +5 -4
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/strikes.py +40 -7
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/services/operator_service.py +13 -5
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/web/app.py +10 -7
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/web/routes.py +17 -9
- csm_dashboard-0.3.1/tests/conftest.py +108 -0
- csm_dashboard-0.3.1/tests/unit/test_cache.py +162 -0
- csm_dashboard-0.3.1/tests/unit/test_config.py +91 -0
- csm_dashboard-0.3.1/tests/unit/test_strikes.py +115 -0
- csm_dashboard-0.3.1/tests/unit/test_types.py +207 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.dockerignore +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.env.example +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.github/workflows/docker-publish.yaml +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.github/workflows/release.yaml +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/.gitignore +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/Dockerfile +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/README.md +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/docker-compose.yml +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/img/csm-dash-cli.png +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/img/csm-dash-web.png +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/img/logo.png +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/requirements.txt +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/CSAccounting.json +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/CSFeeDistributor.json +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/CSModule.json +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/abis/stETH.json +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/cli/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/config.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/core/contracts.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/beacon.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/cache.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/etherscan.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/ipfs_logs.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/known_cids.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/lido_api.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/data/rewards_tree.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/main.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/services/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/src/web/__init__.py +0 -0
- {csm_dashboard-0.3.0 → csm_dashboard-0.3.1}/tests/__init__.py +0 -0
|
@@ -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":
|
|
38
|
-
"required_bond_eth":
|
|
39
|
-
"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":
|
|
41
|
+
"cumulative_rewards_eth": str(rewards.cumulative_rewards_eth),
|
|
42
42
|
"distributed_shares": rewards.distributed_shares,
|
|
43
|
-
"distributed_eth":
|
|
43
|
+
"distributed_eth": str(rewards.distributed_eth),
|
|
44
44
|
"unclaimed_shares": rewards.unclaimed_shares,
|
|
45
|
-
"unclaimed_eth":
|
|
46
|
-
"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":
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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":
|
|
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}/
|
|
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
|
|
148
|
-
validators_near_ejection: int = 0 # Validators
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 ==
|
|
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
|
-
|
|
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 ===
|
|
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}
|
|
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 >=
|
|
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 >=
|
|
548
|
-
message = `Validator ejectable (${h.strikes.validators_at_risk} at
|
|
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 ===
|
|
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
|
|
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":
|
|
50
|
-
"required_bond_eth":
|
|
51
|
-
"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":
|
|
53
|
+
"cumulative_rewards_eth": str(rewards.cumulative_rewards_eth),
|
|
54
54
|
"distributed_shares": rewards.distributed_shares,
|
|
55
|
-
"distributed_eth":
|
|
55
|
+
"distributed_eth": str(rewards.distributed_eth),
|
|
56
56
|
"unclaimed_shares": rewards.unclaimed_shares,
|
|
57
|
-
"unclaimed_eth":
|
|
58
|
-
"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":
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|