csm-dashboard 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {csm_dashboard-0.3.0.dist-info → csm_dashboard-0.3.2.dist-info}/METADATA +1 -1
- {csm_dashboard-0.3.0.dist-info → csm_dashboard-0.3.2.dist-info}/RECORD +11 -11
- src/cli/commands.py +36 -34
- src/core/types.py +3 -2
- src/data/onchain.py +5 -4
- src/data/strikes.py +40 -7
- src/services/operator_service.py +13 -5
- src/web/app.py +18 -15
- src/web/routes.py +17 -9
- {csm_dashboard-0.3.0.dist-info → csm_dashboard-0.3.2.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.3.0.dist-info → csm_dashboard-0.3.2.dist-info}/entry_points.txt +0 -0
|
@@ -6,11 +6,11 @@ src/abis/CSModule.json,sha256=T6D6aInBoqVH3ZD6U6p3lrPa3t_ucA9V83IwE80kOuU,1687
|
|
|
6
6
|
src/abis/__init__.py,sha256=9HV2hKMGSoNAi8evsjzymTr4V4kowITNsX1-LPu6l98,20
|
|
7
7
|
src/abis/stETH.json,sha256=ldxbIRrtt8ePVFewJ9Tnz4qUGFmuOXa41GN1t3tnWEg,1106
|
|
8
8
|
src/cli/__init__.py,sha256=mgHAwomqzAhOHJnlWrWtCguhGzhDWlkCvkzKsfoFsds,35
|
|
9
|
-
src/cli/commands.py,sha256=
|
|
9
|
+
src/cli/commands.py,sha256=B8-TUAPyQPanTGMd_nom6eo-PhnshrEiJD0NwLkdsbw,34954
|
|
10
10
|
src/core/__init__.py,sha256=ZDHZojANK1ZFpn5lQROERTo97MYQFAxqA9APvs7ruEQ,57
|
|
11
11
|
src/core/config.py,sha256=NqkS2zsWyDMDz-q0eE9akHmTE9kZ-snGdTxLn-Dks4E,1532
|
|
12
12
|
src/core/contracts.py,sha256=8Y72h5uUTaIMLWYdtu5O2Bjw1hTyIHOegaDs0W8r74g,525
|
|
13
|
-
src/core/types.py,sha256=
|
|
13
|
+
src/core/types.py,sha256=ymHofuF5kkZDQpi-RyO4ZRHGJqz-_fw21lNRY-bnefA,7311
|
|
14
14
|
src/data/__init__.py,sha256=DItA8aEp8Lbr0uFlJVppMaTtVEEznoA1hkRpH-vfHhk,57
|
|
15
15
|
src/data/beacon.py,sha256=9oaR7TO2WcP_D3jVTzcd9WE6NbF2WnqQDW_y5M8GsZ0,13511
|
|
16
16
|
src/data/cache.py,sha256=w1iv4rM-FVgFlaSNYdQA3CfqyhZo9-gqbZwKmzKY0Ps,2134
|
|
@@ -18,15 +18,15 @@ src/data/etherscan.py,sha256=UeCucGPd4m39yl13uogY7hCBm46Ge7nZQ96V3eoOmu4,5064
|
|
|
18
18
|
src/data/ipfs_logs.py,sha256=gXUTP9dmZ_e7gW6ouotJ1r_72ixq2q_32y2evYQf0DM,10709
|
|
19
19
|
src/data/known_cids.py,sha256=onwTTNvAv4h0-3LZgLhqMlKzuNH2VhBqQGZ9fm8GyoE,1705
|
|
20
20
|
src/data/lido_api.py,sha256=477-1vqlMAwLaisaa61T9AxJuSUV6W7NLg1cxvdmheY,4884
|
|
21
|
-
src/data/onchain.py,sha256=
|
|
21
|
+
src/data/onchain.py,sha256=3B2dL8PHQL2BkSySUYOq1jwPI3WkUTOFPbiVkECiiMU,16716
|
|
22
22
|
src/data/rewards_tree.py,sha256=a-MO14b4COjOvy59FPd0jaf1dkgidlqCQKAFDinwRJU,1894
|
|
23
|
-
src/data/strikes.py,sha256=
|
|
23
|
+
src/data/strikes.py,sha256=b68exKntZpzjiEBftiI3AcvEclHp7vg5lsOiG2a7Kzo,9046
|
|
24
24
|
src/services/__init__.py,sha256=MC7blFLAMazErCWuyYXvS6sO3uZm1z_RUOtnlIK0kSo,38
|
|
25
|
-
src/services/operator_service.py,sha256=
|
|
25
|
+
src/services/operator_service.py,sha256=Q_Sc65T06IMHUAP_zgROGkZSbEyFPSL4B4ssVsFEUSg,29562
|
|
26
26
|
src/web/__init__.py,sha256=iI2c5xxXmzsNxIetm0P2qE3uVsT-ClsMfzn620r5YTU,40
|
|
27
|
-
src/web/app.py,sha256=
|
|
28
|
-
src/web/routes.py,sha256=
|
|
29
|
-
csm_dashboard-0.3.
|
|
30
|
-
csm_dashboard-0.3.
|
|
31
|
-
csm_dashboard-0.3.
|
|
32
|
-
csm_dashboard-0.3.
|
|
27
|
+
src/web/app.py,sha256=iLnkIi8GF5ybWwIQAmpNMxMoBaA0ob1xmfQaJrnpZ4E,32604
|
|
28
|
+
src/web/routes.py,sha256=cswOYAsw9x4tEmGaHBDbkwUvML80M1onmDfRlhtQkGo,9895
|
|
29
|
+
csm_dashboard-0.3.2.dist-info/METADATA,sha256=S7WVOP9xDCfAx32g1My2AuSAi-uUsaFm910gDk5lR7I,12552
|
|
30
|
+
csm_dashboard-0.3.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
31
|
+
csm_dashboard-0.3.2.dist-info/entry_points.txt,sha256=P1Ul8ALIPBwDlVlXqTPuzJ64xxRpIJsYW8U73Tyjgtg,37
|
|
32
|
+
csm_dashboard-0.3.2.dist-info/RECORD,,
|
src/cli/commands.py
CHANGED
|
@@ -34,16 +34,16 @@ def format_as_api_json(rewards: OperatorRewards, include_validators: bool = Fals
|
|
|
34
34
|
"curve_id": rewards.curve_id,
|
|
35
35
|
"operator_type": rewards.operator_type,
|
|
36
36
|
"rewards": {
|
|
37
|
-
"current_bond_eth":
|
|
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:
|
src/core/types.py
CHANGED
|
@@ -144,10 +144,11 @@ class StrikeSummary(BaseModel):
|
|
|
144
144
|
"""Summary of strikes for an operator."""
|
|
145
145
|
|
|
146
146
|
total_validators_with_strikes: int = 0
|
|
147
|
-
validators_at_risk: int = 0 # Validators
|
|
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):
|
src/data/onchain.py
CHANGED
|
@@ -339,8 +339,9 @@ class OnChainDataProvider:
|
|
|
339
339
|
"""
|
|
340
340
|
Get withdrawal history for an operator's reward address.
|
|
341
341
|
|
|
342
|
-
Queries stETH Transfer events from
|
|
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)
|
src/data/strikes.py
CHANGED
|
@@ -13,6 +13,22 @@ from ..core.config import get_settings
|
|
|
13
13
|
from .cache import cached
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
# Strike thresholds by operator type (curve_id)
|
|
17
|
+
# Default (Permissionless): 3 strikes till key exit
|
|
18
|
+
# ICS (Identified Community Staker): 4 strikes till key exit
|
|
19
|
+
STRIKE_THRESHOLDS = {
|
|
20
|
+
0: 3, # Permissionless (Legacy)
|
|
21
|
+
1: 4, # ICS/Legacy EA
|
|
22
|
+
2: 3, # Permissionless (current)
|
|
23
|
+
}
|
|
24
|
+
DEFAULT_STRIKE_THRESHOLD = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_strike_threshold(curve_id: int) -> int:
|
|
28
|
+
"""Get the strike threshold for ejection based on operator curve_id."""
|
|
29
|
+
return STRIKE_THRESHOLDS.get(curve_id, DEFAULT_STRIKE_THRESHOLD)
|
|
30
|
+
|
|
31
|
+
|
|
16
32
|
@dataclass
|
|
17
33
|
class ValidatorStrikes:
|
|
18
34
|
"""Strike information for a single validator."""
|
|
@@ -20,7 +36,8 @@ class ValidatorStrikes:
|
|
|
20
36
|
pubkey: str
|
|
21
37
|
strikes: list[int] # Array of 6 values (0 or 1) representing strikes per frame
|
|
22
38
|
strike_count: int # Total strikes in the 6-frame window
|
|
23
|
-
|
|
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:
|
src/services/operator_service.py
CHANGED
|
@@ -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.
|
src/web/app.py
CHANGED
|
@@ -327,13 +327,13 @@ def create_app() -> FastAPI:
|
|
|
327
327
|
document.getElementById('active-validators').textContent = data.validators.active;
|
|
328
328
|
document.getElementById('exited-validators').textContent = data.validators.exited;
|
|
329
329
|
|
|
330
|
-
document.getElementById('current-bond').textContent = (data.rewards?.current_bond_eth ?? 0).toFixed(6);
|
|
331
|
-
document.getElementById('required-bond').textContent = (data.rewards?.required_bond_eth ?? 0).toFixed(6);
|
|
332
|
-
document.getElementById('excess-bond').textContent = (data.rewards?.excess_bond_eth ?? 0).toFixed(6);
|
|
333
|
-
document.getElementById('cumulative-rewards').textContent = (data.rewards?.cumulative_rewards_eth ?? 0).toFixed(6);
|
|
334
|
-
document.getElementById('distributed-rewards').textContent = (data.rewards?.distributed_eth ?? 0).toFixed(6);
|
|
335
|
-
document.getElementById('unclaimed-rewards').textContent = (data.rewards?.unclaimed_eth ?? 0).toFixed(6);
|
|
336
|
-
document.getElementById('total-claimable').textContent = (data.rewards?.total_claimable_eth ?? 0).toFixed(6);
|
|
330
|
+
document.getElementById('current-bond').textContent = parseFloat(data.rewards?.current_bond_eth ?? 0).toFixed(6);
|
|
331
|
+
document.getElementById('required-bond').textContent = parseFloat(data.rewards?.required_bond_eth ?? 0).toFixed(6);
|
|
332
|
+
document.getElementById('excess-bond').textContent = parseFloat(data.rewards?.excess_bond_eth ?? 0).toFixed(6);
|
|
333
|
+
document.getElementById('cumulative-rewards').textContent = parseFloat(data.rewards?.cumulative_rewards_eth ?? 0).toFixed(6);
|
|
334
|
+
document.getElementById('distributed-rewards').textContent = parseFloat(data.rewards?.distributed_eth ?? 0).toFixed(6);
|
|
335
|
+
document.getElementById('unclaimed-rewards').textContent = parseFloat(data.rewards?.unclaimed_eth ?? 0).toFixed(6);
|
|
336
|
+
document.getElementById('total-claimable').textContent = parseFloat(data.rewards?.total_claimable_eth ?? 0).toFixed(6);
|
|
337
337
|
|
|
338
338
|
results.classList.remove('hidden');
|
|
339
339
|
} catch (err) {
|
|
@@ -413,7 +413,7 @@ def create_app() -> FastAPI:
|
|
|
413
413
|
if (h.bond_healthy) {
|
|
414
414
|
document.getElementById('health-bond').innerHTML = '<span class="text-green-400">HEALTHY</span>';
|
|
415
415
|
} else {
|
|
416
|
-
document.getElementById('health-bond').innerHTML = `<span class="text-red-400">DEFICIT -${h.bond_deficit_eth.toFixed(4)} ETH</span>`;
|
|
416
|
+
document.getElementById('health-bond').innerHTML = `<span class="text-red-400">DEFICIT -${parseFloat(h.bond_deficit_eth).toFixed(4)} ETH</span>`;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
419
|
// Stuck validators
|
|
@@ -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 =
|
src/web/routes.py
CHANGED
|
@@ -46,16 +46,16 @@ async def get_operator(
|
|
|
46
46
|
"curve_id": rewards.curve_id,
|
|
47
47
|
"operator_type": rewards.operator_type,
|
|
48
48
|
"rewards": {
|
|
49
|
-
"current_bond_eth":
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|