csm-dashboard 0.2.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.1.dist-info}/METADATA +90 -25
- csm_dashboard-0.3.1.dist-info/RECORD +32 -0
- src/abis/CSAccounting.json +22 -0
- src/abis/stETH.json +10 -0
- src/cli/commands.py +266 -45
- src/core/config.py +3 -0
- src/core/types.py +77 -5
- src/data/etherscan.py +60 -0
- src/data/ipfs_logs.py +42 -2
- src/data/lido_api.py +105 -0
- src/data/onchain.py +191 -0
- src/data/strikes.py +40 -7
- src/services/operator_service.py +352 -34
- src/web/app.py +10 -7
- src/web/routes.py +77 -11
- csm_dashboard-0.2.2.dist-info/RECORD +0 -32
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.1.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.1.dist-info}/entry_points.txt +0 -0
src/services/operator_service.py
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from decimal import Decimal
|
|
4
4
|
|
|
5
|
-
from ..core.types import
|
|
5
|
+
from ..core.types import (
|
|
6
|
+
APYMetrics,
|
|
7
|
+
BondSummary,
|
|
8
|
+
DistributionFrame,
|
|
9
|
+
HealthStatus,
|
|
10
|
+
OperatorRewards,
|
|
11
|
+
StrikeSummary,
|
|
12
|
+
WithdrawalEvent,
|
|
13
|
+
)
|
|
6
14
|
from ..data.beacon import (
|
|
7
15
|
BeaconDataProvider,
|
|
8
16
|
ValidatorInfo,
|
|
@@ -13,7 +21,7 @@ from ..data.beacon import (
|
|
|
13
21
|
epoch_to_datetime,
|
|
14
22
|
get_earliest_activation,
|
|
15
23
|
)
|
|
16
|
-
from ..data.ipfs_logs import IPFSLogProvider
|
|
24
|
+
from ..data.ipfs_logs import BEACON_GENESIS, IPFSLogProvider, epoch_to_datetime as epoch_to_dt
|
|
17
25
|
from ..data.lido_api import LidoAPIProvider
|
|
18
26
|
from ..data.onchain import OnChainDataProvider
|
|
19
27
|
from ..data.rewards_tree import RewardsTreeProvider
|
|
@@ -32,7 +40,7 @@ class OperatorService:
|
|
|
32
40
|
self.strikes = StrikesProvider(rpc_url)
|
|
33
41
|
|
|
34
42
|
async def get_operator_by_address(
|
|
35
|
-
self, address: str, include_validators: bool = False
|
|
43
|
+
self, address: str, include_validators: bool = False, include_history: bool = False, include_withdrawals: bool = False
|
|
36
44
|
) -> OperatorRewards | None:
|
|
37
45
|
"""
|
|
38
46
|
Main entry point: get complete rewards data for an address.
|
|
@@ -43,10 +51,10 @@ class OperatorService:
|
|
|
43
51
|
if operator_id is None:
|
|
44
52
|
return None
|
|
45
53
|
|
|
46
|
-
return await self.get_operator_by_id(operator_id, include_validators)
|
|
54
|
+
return await self.get_operator_by_id(operator_id, include_validators, include_history, include_withdrawals)
|
|
47
55
|
|
|
48
56
|
async def get_operator_by_id(
|
|
49
|
-
self, operator_id: int, include_validators: bool = False
|
|
57
|
+
self, operator_id: int, include_validators: bool = False, include_history: bool = False, include_withdrawals: bool = False
|
|
50
58
|
) -> OperatorRewards | None:
|
|
51
59
|
"""Get complete rewards data for an operator ID."""
|
|
52
60
|
from web3.exceptions import ContractLogicError
|
|
@@ -58,36 +66,41 @@ class OperatorService:
|
|
|
58
66
|
# Operator ID doesn't exist on-chain
|
|
59
67
|
return None
|
|
60
68
|
|
|
61
|
-
# Step 2: Get bond
|
|
69
|
+
# Step 2: Get bond curve and operator type
|
|
70
|
+
curve_id = await self.onchain.get_bond_curve_id(operator_id)
|
|
71
|
+
operator_type = self.onchain.get_operator_type_name(curve_id)
|
|
72
|
+
|
|
73
|
+
# Step 3: Get bond summary
|
|
62
74
|
bond = await self.onchain.get_bond_summary(operator_id)
|
|
63
75
|
|
|
64
|
-
# Step
|
|
76
|
+
# Step 4: Get rewards from merkle tree
|
|
65
77
|
rewards_info = await self.rewards_tree.get_operator_rewards(operator_id)
|
|
66
78
|
|
|
67
|
-
# Step
|
|
79
|
+
# Step 5: Get already distributed (claimed) shares
|
|
68
80
|
distributed = await self.onchain.get_distributed_shares(operator_id)
|
|
69
81
|
|
|
70
|
-
# Step
|
|
82
|
+
# Step 6: Calculate unclaimed
|
|
71
83
|
cumulative_shares = (
|
|
72
84
|
rewards_info.cumulative_fee_shares if rewards_info else 0
|
|
73
85
|
)
|
|
74
86
|
unclaimed_shares = max(0, cumulative_shares - distributed)
|
|
75
87
|
|
|
76
|
-
# Step
|
|
88
|
+
# Step 7: Convert shares to ETH
|
|
77
89
|
unclaimed_eth = await self.onchain.shares_to_eth(unclaimed_shares)
|
|
78
90
|
cumulative_eth = await self.onchain.shares_to_eth(cumulative_shares)
|
|
79
91
|
distributed_eth = await self.onchain.shares_to_eth(distributed)
|
|
80
92
|
|
|
81
|
-
# Step
|
|
93
|
+
# Step 8: Calculate total claimable
|
|
82
94
|
total_claimable = bond.excess_bond_eth + unclaimed_eth
|
|
83
95
|
|
|
84
|
-
# Step
|
|
96
|
+
# Step 9: Get validator details if requested
|
|
85
97
|
validator_details: list[ValidatorInfo] = []
|
|
86
98
|
validators_by_status: dict[str, int] | None = None
|
|
87
99
|
avg_effectiveness: float | None = None
|
|
88
100
|
apy_metrics: APYMetrics | None = None
|
|
89
101
|
active_since = None
|
|
90
102
|
health_status: HealthStatus | None = None
|
|
103
|
+
withdrawals: list[WithdrawalEvent] | None = None
|
|
91
104
|
|
|
92
105
|
if include_validators and operator.total_deposited_keys > 0:
|
|
93
106
|
# Get validator pubkeys
|
|
@@ -100,24 +113,33 @@ class OperatorService:
|
|
|
100
113
|
avg_effectiveness = calculate_avg_effectiveness(validator_details)
|
|
101
114
|
active_since = get_earliest_activation(validator_details)
|
|
102
115
|
|
|
103
|
-
# Step
|
|
116
|
+
# Step 10: Calculate APY metrics (using historical IPFS data)
|
|
104
117
|
apy_metrics = await self.calculate_apy_metrics(
|
|
105
118
|
operator_id=operator_id,
|
|
106
119
|
bond_eth=bond.current_bond_eth,
|
|
120
|
+
curve_id=curve_id,
|
|
121
|
+
include_history=include_history,
|
|
107
122
|
)
|
|
108
123
|
|
|
109
|
-
# Step
|
|
124
|
+
# Step 11: Calculate health status
|
|
110
125
|
health_status = await self.calculate_health_status(
|
|
111
126
|
operator_id=operator_id,
|
|
112
127
|
bond=bond,
|
|
113
128
|
stuck_validators_count=operator.stuck_validators_count,
|
|
114
129
|
validator_details=validator_details,
|
|
130
|
+
curve_id=curve_id,
|
|
115
131
|
)
|
|
116
132
|
|
|
133
|
+
# Step 12: Fetch withdrawal history if requested
|
|
134
|
+
if include_withdrawals:
|
|
135
|
+
withdrawals = await self.get_withdrawal_history(operator_id)
|
|
136
|
+
|
|
117
137
|
return OperatorRewards(
|
|
118
138
|
node_operator_id=operator_id,
|
|
119
139
|
manager_address=operator.manager_address,
|
|
120
140
|
reward_address=operator.reward_address,
|
|
141
|
+
curve_id=curve_id,
|
|
142
|
+
operator_type=operator_type,
|
|
121
143
|
current_bond_eth=bond.current_bond_eth,
|
|
122
144
|
required_bond_eth=bond.required_bond_eth,
|
|
123
145
|
excess_bond_eth=bond.excess_bond_eth,
|
|
@@ -137,6 +159,7 @@ class OperatorService:
|
|
|
137
159
|
apy=apy_metrics,
|
|
138
160
|
active_since=active_since,
|
|
139
161
|
health=health_status,
|
|
162
|
+
withdrawals=withdrawals,
|
|
140
163
|
)
|
|
141
164
|
|
|
142
165
|
async def get_all_operators_with_rewards(self) -> list[int]:
|
|
@@ -147,18 +170,43 @@ class OperatorService:
|
|
|
147
170
|
self,
|
|
148
171
|
operator_id: int,
|
|
149
172
|
bond_eth: Decimal,
|
|
173
|
+
curve_id: int = 0,
|
|
174
|
+
include_history: bool = False,
|
|
150
175
|
) -> APYMetrics:
|
|
151
176
|
"""Calculate APY metrics for an operator using historical IPFS data.
|
|
152
177
|
|
|
153
178
|
Note: Validator APY (consensus rewards) is NOT calculated because CSM operators
|
|
154
179
|
don't receive those rewards directly - they go to Lido protocol and are
|
|
155
180
|
redistributed via CSM reward distributions (captured in reward_apy).
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
operator_id: The operator ID
|
|
184
|
+
bond_eth: Current bond in ETH
|
|
185
|
+
curve_id: Bond curve (0=Permissionless, 1=ICS/Legacy EA)
|
|
186
|
+
include_history: If True, populate the frames list with all historical data
|
|
187
|
+
and calculate accurate per-frame lifetime APY
|
|
156
188
|
"""
|
|
157
189
|
historical_reward_apy_28d = None
|
|
158
190
|
historical_reward_apy_ltd = None
|
|
191
|
+
previous_distribution_eth = None
|
|
192
|
+
previous_distribution_apy = None
|
|
193
|
+
previous_net_apy = None
|
|
194
|
+
current_distribution_eth = None
|
|
195
|
+
current_distribution_apy = None
|
|
196
|
+
next_distribution_date = None
|
|
197
|
+
next_distribution_est_eth = None
|
|
198
|
+
lifetime_distribution_eth = None
|
|
199
|
+
# Accurate lifetime APY (calculated with per-frame bond when include_history=True)
|
|
200
|
+
lifetime_reward_apy = None
|
|
201
|
+
lifetime_bond_apy = None
|
|
202
|
+
lifetime_net_apy = None
|
|
203
|
+
frame_list: list[DistributionFrame] | None = None
|
|
204
|
+
frames = []
|
|
159
205
|
|
|
160
206
|
# 1. Try to get historical APY from IPFS distribution logs
|
|
161
|
-
|
|
207
|
+
# Minimum bond threshold: 0.01 ETH (dust amounts produce nonsensical APY)
|
|
208
|
+
MIN_BOND_ETH = Decimal("0.01")
|
|
209
|
+
if bond_eth >= MIN_BOND_ETH:
|
|
162
210
|
try:
|
|
163
211
|
# Query historical log CIDs from contract events
|
|
164
212
|
log_history = await self.onchain.get_distribution_log_history()
|
|
@@ -170,14 +218,59 @@ class OperatorService:
|
|
|
170
218
|
)
|
|
171
219
|
|
|
172
220
|
if frames:
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
221
|
+
# Convert all frame shares to ETH values
|
|
222
|
+
# IPFS logs store distributed_rewards in stETH shares, not ETH
|
|
223
|
+
# We need to convert shares to ETH for accurate display and APY calculation
|
|
224
|
+
total_shares = sum(f.distributed_rewards for f in frames)
|
|
225
|
+
lifetime_distribution_eth = float(
|
|
226
|
+
await self.onchain.shares_to_eth(total_shares)
|
|
178
227
|
)
|
|
179
|
-
|
|
180
|
-
|
|
228
|
+
|
|
229
|
+
# Extract current frame data (most recent)
|
|
230
|
+
current_frame = frames[-1]
|
|
231
|
+
current_eth = await self.onchain.shares_to_eth(
|
|
232
|
+
current_frame.distributed_rewards
|
|
233
|
+
)
|
|
234
|
+
current_days = self.ipfs_logs.calculate_frame_duration_days(current_frame)
|
|
235
|
+
current_distribution_eth = float(current_eth)
|
|
236
|
+
if current_days > 0 and bond_eth >= MIN_BOND_ETH:
|
|
237
|
+
current_distribution_apy = round(
|
|
238
|
+
float(current_eth / bond_eth) * (365.0 / current_days) * 100, 2
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Extract previous frame data (second-to-last)
|
|
242
|
+
if len(frames) >= 2:
|
|
243
|
+
previous_frame = frames[-2]
|
|
244
|
+
prev_eth = await self.onchain.shares_to_eth(
|
|
245
|
+
previous_frame.distributed_rewards
|
|
246
|
+
)
|
|
247
|
+
prev_days = self.ipfs_logs.calculate_frame_duration_days(previous_frame)
|
|
248
|
+
previous_distribution_eth = float(prev_eth)
|
|
249
|
+
if prev_days > 0 and bond_eth >= MIN_BOND_ETH:
|
|
250
|
+
previous_distribution_apy = round(
|
|
251
|
+
float(prev_eth / bond_eth) * (365.0 / prev_days) * 100, 2
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Calculate APY using ETH values (now that we have them)
|
|
255
|
+
# Calculate 28-day APY (current frame)
|
|
256
|
+
if current_distribution_apy is not None:
|
|
257
|
+
historical_reward_apy_28d = current_distribution_apy
|
|
258
|
+
# NOTE: Lifetime APY is intentionally NOT calculated because:
|
|
259
|
+
# - It uses current bond as denominator for all historical rewards
|
|
260
|
+
# - This produces misleading values for operators who grew over time
|
|
261
|
+
# - We keep lifetime_distribution_eth (ETH totals are accurate)
|
|
262
|
+
# historical_reward_apy_ltd remains None
|
|
263
|
+
|
|
264
|
+
# Estimate next distribution date (~28 days after current frame ends)
|
|
265
|
+
# Frame duration ≈ 28 days = ~6300 epochs
|
|
266
|
+
next_epoch = current_frame.end_epoch + 6300
|
|
267
|
+
next_distribution_date = epoch_to_dt(next_epoch).isoformat()
|
|
268
|
+
|
|
269
|
+
# Estimate next distribution ETH based on current daily rate
|
|
270
|
+
if current_days > 0:
|
|
271
|
+
daily_rate = current_eth / Decimal(current_days)
|
|
272
|
+
next_distribution_est_eth = float(daily_rate * Decimal(28))
|
|
273
|
+
|
|
181
274
|
except Exception:
|
|
182
275
|
# If historical APY calculation fails, continue without it
|
|
183
276
|
pass
|
|
@@ -186,26 +279,223 @@ class OperatorService:
|
|
|
186
279
|
steth_data = await self.lido_api.get_steth_apr()
|
|
187
280
|
bond_apy = steth_data.get("apr")
|
|
188
281
|
|
|
189
|
-
# 3. Net APY
|
|
282
|
+
# 3. Net APY calculations
|
|
190
283
|
net_apy_28d = None
|
|
191
284
|
net_apy_ltd = None
|
|
192
285
|
|
|
286
|
+
# Current frame net APY (historical_reward_apy_28d is basically current frame APY)
|
|
193
287
|
if historical_reward_apy_28d is not None and bond_apy is not None:
|
|
194
|
-
net_apy_28d = historical_reward_apy_28d + bond_apy
|
|
288
|
+
net_apy_28d = round(historical_reward_apy_28d + bond_apy, 2)
|
|
195
289
|
elif bond_apy is not None:
|
|
196
|
-
net_apy_28d = bond_apy
|
|
290
|
+
net_apy_28d = round(bond_apy, 2)
|
|
291
|
+
|
|
292
|
+
# Lifetime net APY - intentionally NOT calculated
|
|
293
|
+
# (same reason as historical_reward_apy_ltd - can't accurately calculate without historical bond)
|
|
294
|
+
# net_apy_ltd remains None
|
|
295
|
+
|
|
296
|
+
# Previous frame net APY calculation is moved after we know previous_bond_apy
|
|
297
|
+
# (calculated in section 4 below)
|
|
298
|
+
|
|
299
|
+
# 4. Calculate bond stETH earnings (from stETH rebasing)
|
|
300
|
+
# Formula: bond_eth * (apr / 100) * (duration_days / 365)
|
|
301
|
+
# Uses historical APR from Lido subgraph when available
|
|
302
|
+
# When include_history=True and we have per-frame validator counts, use accurate bond
|
|
303
|
+
previous_bond_eth = None
|
|
304
|
+
current_bond_eth = None
|
|
305
|
+
lifetime_bond_eth = None
|
|
306
|
+
previous_net_total_eth = None
|
|
307
|
+
current_net_total_eth = None
|
|
308
|
+
lifetime_net_total_eth = None
|
|
309
|
+
previous_bond_apr = None # Track which APR was used
|
|
310
|
+
current_bond_apr = None
|
|
311
|
+
previous_bond_apy = None # Bond APY for previous frame (for accurate previous_net_apy)
|
|
312
|
+
|
|
313
|
+
# Fetch historical APR data (returns [] if no API key)
|
|
314
|
+
historical_apr_data = await self.lido_api.get_historical_apr_data()
|
|
315
|
+
|
|
316
|
+
if bond_eth >= MIN_BOND_ETH:
|
|
317
|
+
# Previous frame bond earnings
|
|
318
|
+
if frames and len(frames) >= 2:
|
|
319
|
+
prev_frame = frames[-2]
|
|
320
|
+
prev_days = self.ipfs_logs.calculate_frame_duration_days(prev_frame)
|
|
321
|
+
if prev_days > 0:
|
|
322
|
+
# Use average historical APR for the frame period
|
|
323
|
+
prev_start_ts = BEACON_GENESIS + (prev_frame.start_epoch * 384)
|
|
324
|
+
prev_end_ts = BEACON_GENESIS + (prev_frame.end_epoch * 384)
|
|
325
|
+
prev_apr = self.lido_api.get_average_apr_for_range(
|
|
326
|
+
historical_apr_data, prev_start_ts, prev_end_ts
|
|
327
|
+
)
|
|
328
|
+
if prev_apr is None:
|
|
329
|
+
prev_apr = bond_apy
|
|
330
|
+
if prev_apr is not None:
|
|
331
|
+
previous_bond_apr = round(prev_apr, 2)
|
|
332
|
+
previous_bond_apy = previous_bond_apr # Same value, used for net APY
|
|
333
|
+
|
|
334
|
+
# When include_history=True and we have validator count, use per-frame bond
|
|
335
|
+
if include_history and prev_frame.validator_count > 0:
|
|
336
|
+
prev_bond = self.onchain.calculate_required_bond(
|
|
337
|
+
prev_frame.validator_count, curve_id
|
|
338
|
+
)
|
|
339
|
+
previous_bond_eth = round(
|
|
340
|
+
float(prev_bond) * (prev_apr / 100) * (prev_days / 365), 6
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
previous_bond_eth = round(
|
|
344
|
+
float(bond_eth) * (prev_apr / 100) * (prev_days / 365), 6
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Current frame bond earnings
|
|
348
|
+
if frames:
|
|
349
|
+
curr_frame = frames[-1]
|
|
350
|
+
curr_days = self.ipfs_logs.calculate_frame_duration_days(curr_frame)
|
|
351
|
+
if curr_days > 0:
|
|
352
|
+
# Use average historical APR for the frame period
|
|
353
|
+
curr_start_ts = BEACON_GENESIS + (curr_frame.start_epoch * 384)
|
|
354
|
+
curr_end_ts = BEACON_GENESIS + (curr_frame.end_epoch * 384)
|
|
355
|
+
curr_apr = self.lido_api.get_average_apr_for_range(
|
|
356
|
+
historical_apr_data, curr_start_ts, curr_end_ts
|
|
357
|
+
)
|
|
358
|
+
if curr_apr is None:
|
|
359
|
+
curr_apr = bond_apy
|
|
360
|
+
if curr_apr is not None:
|
|
361
|
+
current_bond_apr = round(curr_apr, 2)
|
|
362
|
+
current_bond_eth = round(
|
|
363
|
+
float(bond_eth) * (curr_apr / 100) * (curr_days / 365), 6
|
|
364
|
+
)
|
|
197
365
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
366
|
+
# Lifetime bond earnings (sum of all frame durations with per-frame APR)
|
|
367
|
+
# When include_history=True, calculate accurate lifetime APY with per-frame bond
|
|
368
|
+
if frames:
|
|
369
|
+
lifetime_bond_sum = 0.0
|
|
370
|
+
# For accurate lifetime APY calculation (duration-weighted)
|
|
371
|
+
frame_reward_apys = []
|
|
372
|
+
frame_bond_apys = []
|
|
373
|
+
frame_durations = []
|
|
374
|
+
|
|
375
|
+
for f in frames:
|
|
376
|
+
f_days = self.ipfs_logs.calculate_frame_duration_days(f)
|
|
377
|
+
if f_days > 0:
|
|
378
|
+
# Use average historical APR for each frame period
|
|
379
|
+
f_start_ts = BEACON_GENESIS + (f.start_epoch * 384)
|
|
380
|
+
f_end_ts = BEACON_GENESIS + (f.end_epoch * 384)
|
|
381
|
+
f_apr = self.lido_api.get_average_apr_for_range(
|
|
382
|
+
historical_apr_data, f_start_ts, f_end_ts
|
|
383
|
+
)
|
|
384
|
+
if f_apr is None:
|
|
385
|
+
f_apr = bond_apy
|
|
386
|
+
|
|
387
|
+
if f_apr is not None:
|
|
388
|
+
# When include_history=True and we have validator count, use per-frame bond
|
|
389
|
+
if include_history and f.validator_count > 0:
|
|
390
|
+
f_bond = self.onchain.calculate_required_bond(
|
|
391
|
+
f.validator_count, curve_id
|
|
392
|
+
)
|
|
393
|
+
lifetime_bond_sum += float(f_bond) * (f_apr / 100) * (f_days / 365)
|
|
394
|
+
|
|
395
|
+
# Calculate per-frame reward APY for weighted average
|
|
396
|
+
f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
|
|
397
|
+
if f_bond > 0:
|
|
398
|
+
f_reward_apy = float(f_eth / f_bond) * (365.0 / f_days) * 100
|
|
399
|
+
frame_reward_apys.append(f_reward_apy)
|
|
400
|
+
frame_bond_apys.append(f_apr)
|
|
401
|
+
frame_durations.append(f_days)
|
|
402
|
+
else:
|
|
403
|
+
lifetime_bond_sum += float(bond_eth) * (f_apr / 100) * (f_days / 365)
|
|
404
|
+
|
|
405
|
+
if lifetime_bond_sum > 0:
|
|
406
|
+
lifetime_bond_eth = round(lifetime_bond_sum, 6)
|
|
407
|
+
|
|
408
|
+
# Calculate duration-weighted lifetime APYs when include_history=True
|
|
409
|
+
if include_history and frame_durations:
|
|
410
|
+
total_duration = sum(frame_durations)
|
|
411
|
+
if total_duration > 0:
|
|
412
|
+
# Duration-weighted average reward APY
|
|
413
|
+
lifetime_reward_apy = round(
|
|
414
|
+
sum(apy * dur for apy, dur in zip(frame_reward_apys, frame_durations))
|
|
415
|
+
/ total_duration,
|
|
416
|
+
2
|
|
417
|
+
)
|
|
418
|
+
# Duration-weighted average bond APY
|
|
419
|
+
lifetime_bond_apy = round(
|
|
420
|
+
sum(apy * dur for apy, dur in zip(frame_bond_apys, frame_durations))
|
|
421
|
+
/ total_duration,
|
|
422
|
+
2
|
|
423
|
+
)
|
|
424
|
+
# Net = Reward + Bond
|
|
425
|
+
lifetime_net_apy = round(lifetime_reward_apy + lifetime_bond_apy, 2)
|
|
426
|
+
|
|
427
|
+
# 4b. Previous frame net APY (now that we have previous_bond_apy)
|
|
428
|
+
# Uses the actual APR from the previous frame period instead of current bond_apy
|
|
429
|
+
if previous_distribution_apy is not None:
|
|
430
|
+
prev_bond_apy_to_use = previous_bond_apy if previous_bond_apy is not None else bond_apy
|
|
431
|
+
if prev_bond_apy_to_use is not None:
|
|
432
|
+
previous_net_apy = round(previous_distribution_apy + prev_bond_apy_to_use, 2)
|
|
433
|
+
|
|
434
|
+
# 5. Calculate net totals (Rewards + Bond)
|
|
435
|
+
if previous_distribution_eth is not None or previous_bond_eth is not None:
|
|
436
|
+
previous_net_total_eth = round(
|
|
437
|
+
(previous_distribution_eth or 0) + (previous_bond_eth or 0), 6
|
|
438
|
+
)
|
|
439
|
+
if current_distribution_eth is not None or current_bond_eth is not None:
|
|
440
|
+
current_net_total_eth = round(
|
|
441
|
+
(current_distribution_eth or 0) + (current_bond_eth or 0), 6
|
|
442
|
+
)
|
|
443
|
+
if lifetime_distribution_eth is not None or lifetime_bond_eth is not None:
|
|
444
|
+
lifetime_net_total_eth = round(
|
|
445
|
+
(lifetime_distribution_eth or 0) + (lifetime_bond_eth or 0), 6
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# 6. Build frame history if requested
|
|
449
|
+
if include_history and frames:
|
|
450
|
+
frame_list = []
|
|
451
|
+
for i, f in enumerate(frames):
|
|
452
|
+
# Convert shares to ETH (not just dividing by 10^18)
|
|
453
|
+
f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
|
|
454
|
+
f_days = self.ipfs_logs.calculate_frame_duration_days(f)
|
|
455
|
+
f_apy = None
|
|
456
|
+
if f_days > 0 and bond_eth >= MIN_BOND_ETH:
|
|
457
|
+
f_apy = round(float(f_eth / bond_eth) * (365.0 / f_days) * 100, 2)
|
|
458
|
+
|
|
459
|
+
frame_list.append(
|
|
460
|
+
DistributionFrame(
|
|
461
|
+
frame_number=i + 1,
|
|
462
|
+
start_date=epoch_to_dt(f.start_epoch).isoformat(),
|
|
463
|
+
end_date=epoch_to_dt(f.end_epoch).isoformat(),
|
|
464
|
+
rewards_eth=float(f_eth),
|
|
465
|
+
rewards_shares=f.distributed_rewards,
|
|
466
|
+
duration_days=round(f_days, 1),
|
|
467
|
+
validator_count=f.validator_count,
|
|
468
|
+
apy=f_apy,
|
|
469
|
+
)
|
|
470
|
+
)
|
|
202
471
|
|
|
203
472
|
return APYMetrics(
|
|
473
|
+
previous_distribution_eth=previous_distribution_eth,
|
|
474
|
+
previous_distribution_apy=previous_distribution_apy,
|
|
475
|
+
previous_net_apy=previous_net_apy,
|
|
476
|
+
current_distribution_eth=current_distribution_eth,
|
|
477
|
+
current_distribution_apy=current_distribution_apy,
|
|
478
|
+
next_distribution_date=next_distribution_date,
|
|
479
|
+
next_distribution_est_eth=next_distribution_est_eth,
|
|
480
|
+
lifetime_distribution_eth=lifetime_distribution_eth,
|
|
481
|
+
lifetime_reward_apy=lifetime_reward_apy,
|
|
482
|
+
lifetime_bond_apy=lifetime_bond_apy,
|
|
483
|
+
lifetime_net_apy=lifetime_net_apy,
|
|
204
484
|
historical_reward_apy_28d=historical_reward_apy_28d,
|
|
205
485
|
historical_reward_apy_ltd=historical_reward_apy_ltd,
|
|
206
486
|
bond_apy=bond_apy,
|
|
207
487
|
net_apy_28d=net_apy_28d,
|
|
208
488
|
net_apy_ltd=net_apy_ltd,
|
|
489
|
+
frames=frame_list,
|
|
490
|
+
previous_bond_eth=previous_bond_eth,
|
|
491
|
+
current_bond_eth=current_bond_eth,
|
|
492
|
+
lifetime_bond_eth=lifetime_bond_eth,
|
|
493
|
+
previous_bond_apr=previous_bond_apr,
|
|
494
|
+
current_bond_apr=current_bond_apr,
|
|
495
|
+
uses_historical_apr=bool(historical_apr_data),
|
|
496
|
+
previous_net_total_eth=previous_net_total_eth,
|
|
497
|
+
current_net_total_eth=current_net_total_eth,
|
|
498
|
+
lifetime_net_total_eth=lifetime_net_total_eth,
|
|
209
499
|
)
|
|
210
500
|
|
|
211
501
|
async def calculate_health_status(
|
|
@@ -214,6 +504,7 @@ class OperatorService:
|
|
|
214
504
|
bond: BondSummary,
|
|
215
505
|
stuck_validators_count: int,
|
|
216
506
|
validator_details: list[ValidatorInfo],
|
|
507
|
+
curve_id: int | None = None,
|
|
217
508
|
) -> HealthStatus:
|
|
218
509
|
"""Calculate health status for an operator.
|
|
219
510
|
|
|
@@ -227,16 +518,17 @@ class OperatorService:
|
|
|
227
518
|
slashed_count = count_slashed_validators(validator_details)
|
|
228
519
|
at_risk_count = count_at_risk_validators(validator_details)
|
|
229
520
|
|
|
230
|
-
# Get strikes data
|
|
521
|
+
# Get strikes data (pass curve_id for operator-specific thresholds)
|
|
231
522
|
strike_summary = StrikeSummary()
|
|
232
523
|
try:
|
|
233
|
-
summary = await self.strikes.get_operator_strike_summary(operator_id)
|
|
524
|
+
summary = await self.strikes.get_operator_strike_summary(operator_id, curve_id)
|
|
234
525
|
strike_summary = StrikeSummary(
|
|
235
526
|
total_validators_with_strikes=summary.get("total_validators_with_strikes", 0),
|
|
236
527
|
validators_at_risk=summary.get("validators_at_risk", 0),
|
|
237
528
|
validators_near_ejection=summary.get("validators_near_ejection", 0),
|
|
238
529
|
total_strikes=summary.get("total_strikes", 0),
|
|
239
530
|
max_strikes=summary.get("max_strikes", 0),
|
|
531
|
+
strike_threshold=summary.get("strike_threshold", 3),
|
|
240
532
|
)
|
|
241
533
|
except Exception:
|
|
242
534
|
# If strikes fetch fails, continue with empty summary
|
|
@@ -251,9 +543,14 @@ class OperatorService:
|
|
|
251
543
|
strikes=strike_summary,
|
|
252
544
|
)
|
|
253
545
|
|
|
254
|
-
async def get_operator_strikes(self, operator_id: int):
|
|
255
|
-
"""Get detailed strikes for an operator's validators.
|
|
256
|
-
|
|
546
|
+
async def get_operator_strikes(self, operator_id: int, curve_id: int | None = None):
|
|
547
|
+
"""Get detailed strikes for an operator's validators.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
operator_id: The CSM operator ID
|
|
551
|
+
curve_id: The operator's bond curve ID (determines strike threshold)
|
|
552
|
+
"""
|
|
553
|
+
return await self.strikes.get_operator_strikes(operator_id, curve_id)
|
|
257
554
|
|
|
258
555
|
async def get_recent_frame_dates(self, count: int = 6) -> list[dict]:
|
|
259
556
|
"""Get date ranges for the most recent N distribution frames.
|
|
@@ -318,3 +615,24 @@ class OperatorService:
|
|
|
318
615
|
return get_earliest_activation(validators)
|
|
319
616
|
except Exception:
|
|
320
617
|
return None
|
|
618
|
+
|
|
619
|
+
async def get_withdrawal_history(self, operator_id: int) -> list[WithdrawalEvent]:
|
|
620
|
+
"""Get withdrawal/claim history for an operator.
|
|
621
|
+
|
|
622
|
+
Returns list of WithdrawalEvent objects representing when rewards were claimed.
|
|
623
|
+
"""
|
|
624
|
+
try:
|
|
625
|
+
operator = await self.onchain.get_node_operator(operator_id)
|
|
626
|
+
events = await self.onchain.get_withdrawal_history(operator.reward_address)
|
|
627
|
+
return [
|
|
628
|
+
WithdrawalEvent(
|
|
629
|
+
block_number=e["block_number"],
|
|
630
|
+
timestamp=e["timestamp"],
|
|
631
|
+
shares=e["shares"],
|
|
632
|
+
eth_value=e["eth_value"],
|
|
633
|
+
tx_hash=e["tx_hash"],
|
|
634
|
+
)
|
|
635
|
+
for e in events
|
|
636
|
+
]
|
|
637
|
+
except Exception:
|
|
638
|
+
return []
|
src/web/app.py
CHANGED
|
@@ -473,9 +473,11 @@ def create_app() -> FastAPI:
|
|
|
473
473
|
const opId = document.getElementById('operator-id').textContent;
|
|
474
474
|
const strikesResp = await fetch(`/api/operator/${opId}/strikes`);
|
|
475
475
|
const strikesData = await strikesResp.json();
|
|
476
|
+
const threshold = strikesData.strike_threshold || 3;
|
|
476
477
|
strikesList.innerHTML = strikesData.validators.map(v => {
|
|
478
|
+
const vThreshold = v.strike_threshold || threshold;
|
|
477
479
|
const colorClass = v.at_ejection_risk ? 'text-red-400' :
|
|
478
|
-
(v.strike_count ===
|
|
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 =
|