csm-dashboard 0.2.2__py3-none-any.whl → 0.3.0__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.0.dist-info}/METADATA +90 -25
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.0.dist-info}/RECORD +15 -15
- src/abis/CSAccounting.json +22 -0
- src/abis/stETH.json +10 -0
- src/cli/commands.py +252 -33
- src/core/config.py +3 -0
- src/core/types.py +74 -3
- src/data/etherscan.py +60 -0
- src/data/ipfs_logs.py +42 -2
- src/data/lido_api.py +105 -0
- src/data/onchain.py +190 -0
- src/services/operator_service.py +339 -29
- src/web/routes.py +60 -2
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.0.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.2.2.dist-info → csm_dashboard-0.3.0.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,13 +113,15 @@ 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,
|
|
@@ -114,10 +129,16 @@ class OperatorService:
|
|
|
114
129
|
validator_details=validator_details,
|
|
115
130
|
)
|
|
116
131
|
|
|
132
|
+
# Step 12: Fetch withdrawal history if requested
|
|
133
|
+
if include_withdrawals:
|
|
134
|
+
withdrawals = await self.get_withdrawal_history(operator_id)
|
|
135
|
+
|
|
117
136
|
return OperatorRewards(
|
|
118
137
|
node_operator_id=operator_id,
|
|
119
138
|
manager_address=operator.manager_address,
|
|
120
139
|
reward_address=operator.reward_address,
|
|
140
|
+
curve_id=curve_id,
|
|
141
|
+
operator_type=operator_type,
|
|
121
142
|
current_bond_eth=bond.current_bond_eth,
|
|
122
143
|
required_bond_eth=bond.required_bond_eth,
|
|
123
144
|
excess_bond_eth=bond.excess_bond_eth,
|
|
@@ -137,6 +158,7 @@ class OperatorService:
|
|
|
137
158
|
apy=apy_metrics,
|
|
138
159
|
active_since=active_since,
|
|
139
160
|
health=health_status,
|
|
161
|
+
withdrawals=withdrawals,
|
|
140
162
|
)
|
|
141
163
|
|
|
142
164
|
async def get_all_operators_with_rewards(self) -> list[int]:
|
|
@@ -147,18 +169,43 @@ class OperatorService:
|
|
|
147
169
|
self,
|
|
148
170
|
operator_id: int,
|
|
149
171
|
bond_eth: Decimal,
|
|
172
|
+
curve_id: int = 0,
|
|
173
|
+
include_history: bool = False,
|
|
150
174
|
) -> APYMetrics:
|
|
151
175
|
"""Calculate APY metrics for an operator using historical IPFS data.
|
|
152
176
|
|
|
153
177
|
Note: Validator APY (consensus rewards) is NOT calculated because CSM operators
|
|
154
178
|
don't receive those rewards directly - they go to Lido protocol and are
|
|
155
179
|
redistributed via CSM reward distributions (captured in reward_apy).
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
operator_id: The operator ID
|
|
183
|
+
bond_eth: Current bond in ETH
|
|
184
|
+
curve_id: Bond curve (0=Permissionless, 1=ICS/Legacy EA)
|
|
185
|
+
include_history: If True, populate the frames list with all historical data
|
|
186
|
+
and calculate accurate per-frame lifetime APY
|
|
156
187
|
"""
|
|
157
188
|
historical_reward_apy_28d = None
|
|
158
189
|
historical_reward_apy_ltd = None
|
|
190
|
+
previous_distribution_eth = None
|
|
191
|
+
previous_distribution_apy = None
|
|
192
|
+
previous_net_apy = None
|
|
193
|
+
current_distribution_eth = None
|
|
194
|
+
current_distribution_apy = None
|
|
195
|
+
next_distribution_date = None
|
|
196
|
+
next_distribution_est_eth = None
|
|
197
|
+
lifetime_distribution_eth = None
|
|
198
|
+
# Accurate lifetime APY (calculated with per-frame bond when include_history=True)
|
|
199
|
+
lifetime_reward_apy = None
|
|
200
|
+
lifetime_bond_apy = None
|
|
201
|
+
lifetime_net_apy = None
|
|
202
|
+
frame_list: list[DistributionFrame] | None = None
|
|
203
|
+
frames = []
|
|
159
204
|
|
|
160
205
|
# 1. Try to get historical APY from IPFS distribution logs
|
|
161
|
-
|
|
206
|
+
# Minimum bond threshold: 0.01 ETH (dust amounts produce nonsensical APY)
|
|
207
|
+
MIN_BOND_ETH = Decimal("0.01")
|
|
208
|
+
if bond_eth >= MIN_BOND_ETH:
|
|
162
209
|
try:
|
|
163
210
|
# Query historical log CIDs from contract events
|
|
164
211
|
log_history = await self.onchain.get_distribution_log_history()
|
|
@@ -170,14 +217,59 @@ class OperatorService:
|
|
|
170
217
|
)
|
|
171
218
|
|
|
172
219
|
if frames:
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
220
|
+
# Convert all frame shares to ETH values
|
|
221
|
+
# IPFS logs store distributed_rewards in stETH shares, not ETH
|
|
222
|
+
# We need to convert shares to ETH for accurate display and APY calculation
|
|
223
|
+
total_shares = sum(f.distributed_rewards for f in frames)
|
|
224
|
+
lifetime_distribution_eth = float(
|
|
225
|
+
await self.onchain.shares_to_eth(total_shares)
|
|
178
226
|
)
|
|
179
|
-
|
|
180
|
-
|
|
227
|
+
|
|
228
|
+
# Extract current frame data (most recent)
|
|
229
|
+
current_frame = frames[-1]
|
|
230
|
+
current_eth = await self.onchain.shares_to_eth(
|
|
231
|
+
current_frame.distributed_rewards
|
|
232
|
+
)
|
|
233
|
+
current_days = self.ipfs_logs.calculate_frame_duration_days(current_frame)
|
|
234
|
+
current_distribution_eth = float(current_eth)
|
|
235
|
+
if current_days > 0 and bond_eth >= MIN_BOND_ETH:
|
|
236
|
+
current_distribution_apy = round(
|
|
237
|
+
float(current_eth / bond_eth) * (365.0 / current_days) * 100, 2
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Extract previous frame data (second-to-last)
|
|
241
|
+
if len(frames) >= 2:
|
|
242
|
+
previous_frame = frames[-2]
|
|
243
|
+
prev_eth = await self.onchain.shares_to_eth(
|
|
244
|
+
previous_frame.distributed_rewards
|
|
245
|
+
)
|
|
246
|
+
prev_days = self.ipfs_logs.calculate_frame_duration_days(previous_frame)
|
|
247
|
+
previous_distribution_eth = float(prev_eth)
|
|
248
|
+
if prev_days > 0 and bond_eth >= MIN_BOND_ETH:
|
|
249
|
+
previous_distribution_apy = round(
|
|
250
|
+
float(prev_eth / bond_eth) * (365.0 / prev_days) * 100, 2
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Calculate APY using ETH values (now that we have them)
|
|
254
|
+
# Calculate 28-day APY (current frame)
|
|
255
|
+
if current_distribution_apy is not None:
|
|
256
|
+
historical_reward_apy_28d = current_distribution_apy
|
|
257
|
+
# NOTE: Lifetime APY is intentionally NOT calculated because:
|
|
258
|
+
# - It uses current bond as denominator for all historical rewards
|
|
259
|
+
# - This produces misleading values for operators who grew over time
|
|
260
|
+
# - We keep lifetime_distribution_eth (ETH totals are accurate)
|
|
261
|
+
# historical_reward_apy_ltd remains None
|
|
262
|
+
|
|
263
|
+
# Estimate next distribution date (~28 days after current frame ends)
|
|
264
|
+
# Frame duration ≈ 28 days = ~6300 epochs
|
|
265
|
+
next_epoch = current_frame.end_epoch + 6300
|
|
266
|
+
next_distribution_date = epoch_to_dt(next_epoch).isoformat()
|
|
267
|
+
|
|
268
|
+
# Estimate next distribution ETH based on current daily rate
|
|
269
|
+
if current_days > 0:
|
|
270
|
+
daily_rate = current_eth / Decimal(current_days)
|
|
271
|
+
next_distribution_est_eth = float(daily_rate * Decimal(28))
|
|
272
|
+
|
|
181
273
|
except Exception:
|
|
182
274
|
# If historical APY calculation fails, continue without it
|
|
183
275
|
pass
|
|
@@ -186,26 +278,223 @@ class OperatorService:
|
|
|
186
278
|
steth_data = await self.lido_api.get_steth_apr()
|
|
187
279
|
bond_apy = steth_data.get("apr")
|
|
188
280
|
|
|
189
|
-
# 3. Net APY
|
|
281
|
+
# 3. Net APY calculations
|
|
190
282
|
net_apy_28d = None
|
|
191
283
|
net_apy_ltd = None
|
|
192
284
|
|
|
285
|
+
# Current frame net APY (historical_reward_apy_28d is basically current frame APY)
|
|
193
286
|
if historical_reward_apy_28d is not None and bond_apy is not None:
|
|
194
|
-
net_apy_28d = historical_reward_apy_28d + bond_apy
|
|
287
|
+
net_apy_28d = round(historical_reward_apy_28d + bond_apy, 2)
|
|
195
288
|
elif bond_apy is not None:
|
|
196
|
-
net_apy_28d = bond_apy
|
|
289
|
+
net_apy_28d = round(bond_apy, 2)
|
|
290
|
+
|
|
291
|
+
# Lifetime net APY - intentionally NOT calculated
|
|
292
|
+
# (same reason as historical_reward_apy_ltd - can't accurately calculate without historical bond)
|
|
293
|
+
# net_apy_ltd remains None
|
|
294
|
+
|
|
295
|
+
# Previous frame net APY calculation is moved after we know previous_bond_apy
|
|
296
|
+
# (calculated in section 4 below)
|
|
297
|
+
|
|
298
|
+
# 4. Calculate bond stETH earnings (from stETH rebasing)
|
|
299
|
+
# Formula: bond_eth * (apr / 100) * (duration_days / 365)
|
|
300
|
+
# Uses historical APR from Lido subgraph when available
|
|
301
|
+
# When include_history=True and we have per-frame validator counts, use accurate bond
|
|
302
|
+
previous_bond_eth = None
|
|
303
|
+
current_bond_eth = None
|
|
304
|
+
lifetime_bond_eth = None
|
|
305
|
+
previous_net_total_eth = None
|
|
306
|
+
current_net_total_eth = None
|
|
307
|
+
lifetime_net_total_eth = None
|
|
308
|
+
previous_bond_apr = None # Track which APR was used
|
|
309
|
+
current_bond_apr = None
|
|
310
|
+
previous_bond_apy = None # Bond APY for previous frame (for accurate previous_net_apy)
|
|
311
|
+
|
|
312
|
+
# Fetch historical APR data (returns [] if no API key)
|
|
313
|
+
historical_apr_data = await self.lido_api.get_historical_apr_data()
|
|
314
|
+
|
|
315
|
+
if bond_eth >= MIN_BOND_ETH:
|
|
316
|
+
# Previous frame bond earnings
|
|
317
|
+
if frames and len(frames) >= 2:
|
|
318
|
+
prev_frame = frames[-2]
|
|
319
|
+
prev_days = self.ipfs_logs.calculate_frame_duration_days(prev_frame)
|
|
320
|
+
if prev_days > 0:
|
|
321
|
+
# Use average historical APR for the frame period
|
|
322
|
+
prev_start_ts = BEACON_GENESIS + (prev_frame.start_epoch * 384)
|
|
323
|
+
prev_end_ts = BEACON_GENESIS + (prev_frame.end_epoch * 384)
|
|
324
|
+
prev_apr = self.lido_api.get_average_apr_for_range(
|
|
325
|
+
historical_apr_data, prev_start_ts, prev_end_ts
|
|
326
|
+
)
|
|
327
|
+
if prev_apr is None:
|
|
328
|
+
prev_apr = bond_apy
|
|
329
|
+
if prev_apr is not None:
|
|
330
|
+
previous_bond_apr = round(prev_apr, 2)
|
|
331
|
+
previous_bond_apy = previous_bond_apr # Same value, used for net APY
|
|
332
|
+
|
|
333
|
+
# When include_history=True and we have validator count, use per-frame bond
|
|
334
|
+
if include_history and prev_frame.validator_count > 0:
|
|
335
|
+
prev_bond = self.onchain.calculate_required_bond(
|
|
336
|
+
prev_frame.validator_count, curve_id
|
|
337
|
+
)
|
|
338
|
+
previous_bond_eth = round(
|
|
339
|
+
float(prev_bond) * (prev_apr / 100) * (prev_days / 365), 6
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
previous_bond_eth = round(
|
|
343
|
+
float(bond_eth) * (prev_apr / 100) * (prev_days / 365), 6
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Current frame bond earnings
|
|
347
|
+
if frames:
|
|
348
|
+
curr_frame = frames[-1]
|
|
349
|
+
curr_days = self.ipfs_logs.calculate_frame_duration_days(curr_frame)
|
|
350
|
+
if curr_days > 0:
|
|
351
|
+
# Use average historical APR for the frame period
|
|
352
|
+
curr_start_ts = BEACON_GENESIS + (curr_frame.start_epoch * 384)
|
|
353
|
+
curr_end_ts = BEACON_GENESIS + (curr_frame.end_epoch * 384)
|
|
354
|
+
curr_apr = self.lido_api.get_average_apr_for_range(
|
|
355
|
+
historical_apr_data, curr_start_ts, curr_end_ts
|
|
356
|
+
)
|
|
357
|
+
if curr_apr is None:
|
|
358
|
+
curr_apr = bond_apy
|
|
359
|
+
if curr_apr is not None:
|
|
360
|
+
current_bond_apr = round(curr_apr, 2)
|
|
361
|
+
current_bond_eth = round(
|
|
362
|
+
float(bond_eth) * (curr_apr / 100) * (curr_days / 365), 6
|
|
363
|
+
)
|
|
197
364
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
365
|
+
# Lifetime bond earnings (sum of all frame durations with per-frame APR)
|
|
366
|
+
# When include_history=True, calculate accurate lifetime APY with per-frame bond
|
|
367
|
+
if frames:
|
|
368
|
+
lifetime_bond_sum = 0.0
|
|
369
|
+
# For accurate lifetime APY calculation (duration-weighted)
|
|
370
|
+
frame_reward_apys = []
|
|
371
|
+
frame_bond_apys = []
|
|
372
|
+
frame_durations = []
|
|
373
|
+
|
|
374
|
+
for f in frames:
|
|
375
|
+
f_days = self.ipfs_logs.calculate_frame_duration_days(f)
|
|
376
|
+
if f_days > 0:
|
|
377
|
+
# Use average historical APR for each frame period
|
|
378
|
+
f_start_ts = BEACON_GENESIS + (f.start_epoch * 384)
|
|
379
|
+
f_end_ts = BEACON_GENESIS + (f.end_epoch * 384)
|
|
380
|
+
f_apr = self.lido_api.get_average_apr_for_range(
|
|
381
|
+
historical_apr_data, f_start_ts, f_end_ts
|
|
382
|
+
)
|
|
383
|
+
if f_apr is None:
|
|
384
|
+
f_apr = bond_apy
|
|
385
|
+
|
|
386
|
+
if f_apr is not None:
|
|
387
|
+
# When include_history=True and we have validator count, use per-frame bond
|
|
388
|
+
if include_history and f.validator_count > 0:
|
|
389
|
+
f_bond = self.onchain.calculate_required_bond(
|
|
390
|
+
f.validator_count, curve_id
|
|
391
|
+
)
|
|
392
|
+
lifetime_bond_sum += float(f_bond) * (f_apr / 100) * (f_days / 365)
|
|
393
|
+
|
|
394
|
+
# Calculate per-frame reward APY for weighted average
|
|
395
|
+
f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
|
|
396
|
+
if f_bond > 0:
|
|
397
|
+
f_reward_apy = float(f_eth / f_bond) * (365.0 / f_days) * 100
|
|
398
|
+
frame_reward_apys.append(f_reward_apy)
|
|
399
|
+
frame_bond_apys.append(f_apr)
|
|
400
|
+
frame_durations.append(f_days)
|
|
401
|
+
else:
|
|
402
|
+
lifetime_bond_sum += float(bond_eth) * (f_apr / 100) * (f_days / 365)
|
|
403
|
+
|
|
404
|
+
if lifetime_bond_sum > 0:
|
|
405
|
+
lifetime_bond_eth = round(lifetime_bond_sum, 6)
|
|
406
|
+
|
|
407
|
+
# Calculate duration-weighted lifetime APYs when include_history=True
|
|
408
|
+
if include_history and frame_durations:
|
|
409
|
+
total_duration = sum(frame_durations)
|
|
410
|
+
if total_duration > 0:
|
|
411
|
+
# Duration-weighted average reward APY
|
|
412
|
+
lifetime_reward_apy = round(
|
|
413
|
+
sum(apy * dur for apy, dur in zip(frame_reward_apys, frame_durations))
|
|
414
|
+
/ total_duration,
|
|
415
|
+
2
|
|
416
|
+
)
|
|
417
|
+
# Duration-weighted average bond APY
|
|
418
|
+
lifetime_bond_apy = round(
|
|
419
|
+
sum(apy * dur for apy, dur in zip(frame_bond_apys, frame_durations))
|
|
420
|
+
/ total_duration,
|
|
421
|
+
2
|
|
422
|
+
)
|
|
423
|
+
# Net = Reward + Bond
|
|
424
|
+
lifetime_net_apy = round(lifetime_reward_apy + lifetime_bond_apy, 2)
|
|
425
|
+
|
|
426
|
+
# 4b. Previous frame net APY (now that we have previous_bond_apy)
|
|
427
|
+
# Uses the actual APR from the previous frame period instead of current bond_apy
|
|
428
|
+
if previous_distribution_apy is not None:
|
|
429
|
+
prev_bond_apy_to_use = previous_bond_apy if previous_bond_apy is not None else bond_apy
|
|
430
|
+
if prev_bond_apy_to_use is not None:
|
|
431
|
+
previous_net_apy = round(previous_distribution_apy + prev_bond_apy_to_use, 2)
|
|
432
|
+
|
|
433
|
+
# 5. Calculate net totals (Rewards + Bond)
|
|
434
|
+
if previous_distribution_eth is not None or previous_bond_eth is not None:
|
|
435
|
+
previous_net_total_eth = round(
|
|
436
|
+
(previous_distribution_eth or 0) + (previous_bond_eth or 0), 6
|
|
437
|
+
)
|
|
438
|
+
if current_distribution_eth is not None or current_bond_eth is not None:
|
|
439
|
+
current_net_total_eth = round(
|
|
440
|
+
(current_distribution_eth or 0) + (current_bond_eth or 0), 6
|
|
441
|
+
)
|
|
442
|
+
if lifetime_distribution_eth is not None or lifetime_bond_eth is not None:
|
|
443
|
+
lifetime_net_total_eth = round(
|
|
444
|
+
(lifetime_distribution_eth or 0) + (lifetime_bond_eth or 0), 6
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# 6. Build frame history if requested
|
|
448
|
+
if include_history and frames:
|
|
449
|
+
frame_list = []
|
|
450
|
+
for i, f in enumerate(frames):
|
|
451
|
+
# Convert shares to ETH (not just dividing by 10^18)
|
|
452
|
+
f_eth = await self.onchain.shares_to_eth(f.distributed_rewards)
|
|
453
|
+
f_days = self.ipfs_logs.calculate_frame_duration_days(f)
|
|
454
|
+
f_apy = None
|
|
455
|
+
if f_days > 0 and bond_eth >= MIN_BOND_ETH:
|
|
456
|
+
f_apy = round(float(f_eth / bond_eth) * (365.0 / f_days) * 100, 2)
|
|
457
|
+
|
|
458
|
+
frame_list.append(
|
|
459
|
+
DistributionFrame(
|
|
460
|
+
frame_number=i + 1,
|
|
461
|
+
start_date=epoch_to_dt(f.start_epoch).isoformat(),
|
|
462
|
+
end_date=epoch_to_dt(f.end_epoch).isoformat(),
|
|
463
|
+
rewards_eth=float(f_eth),
|
|
464
|
+
rewards_shares=f.distributed_rewards,
|
|
465
|
+
duration_days=round(f_days, 1),
|
|
466
|
+
validator_count=f.validator_count,
|
|
467
|
+
apy=f_apy,
|
|
468
|
+
)
|
|
469
|
+
)
|
|
202
470
|
|
|
203
471
|
return APYMetrics(
|
|
472
|
+
previous_distribution_eth=previous_distribution_eth,
|
|
473
|
+
previous_distribution_apy=previous_distribution_apy,
|
|
474
|
+
previous_net_apy=previous_net_apy,
|
|
475
|
+
current_distribution_eth=current_distribution_eth,
|
|
476
|
+
current_distribution_apy=current_distribution_apy,
|
|
477
|
+
next_distribution_date=next_distribution_date,
|
|
478
|
+
next_distribution_est_eth=next_distribution_est_eth,
|
|
479
|
+
lifetime_distribution_eth=lifetime_distribution_eth,
|
|
480
|
+
lifetime_reward_apy=lifetime_reward_apy,
|
|
481
|
+
lifetime_bond_apy=lifetime_bond_apy,
|
|
482
|
+
lifetime_net_apy=lifetime_net_apy,
|
|
204
483
|
historical_reward_apy_28d=historical_reward_apy_28d,
|
|
205
484
|
historical_reward_apy_ltd=historical_reward_apy_ltd,
|
|
206
485
|
bond_apy=bond_apy,
|
|
207
486
|
net_apy_28d=net_apy_28d,
|
|
208
487
|
net_apy_ltd=net_apy_ltd,
|
|
488
|
+
frames=frame_list,
|
|
489
|
+
previous_bond_eth=previous_bond_eth,
|
|
490
|
+
current_bond_eth=current_bond_eth,
|
|
491
|
+
lifetime_bond_eth=lifetime_bond_eth,
|
|
492
|
+
previous_bond_apr=previous_bond_apr,
|
|
493
|
+
current_bond_apr=current_bond_apr,
|
|
494
|
+
uses_historical_apr=bool(historical_apr_data),
|
|
495
|
+
previous_net_total_eth=previous_net_total_eth,
|
|
496
|
+
current_net_total_eth=current_net_total_eth,
|
|
497
|
+
lifetime_net_total_eth=lifetime_net_total_eth,
|
|
209
498
|
)
|
|
210
499
|
|
|
211
500
|
async def calculate_health_status(
|
|
@@ -318,3 +607,24 @@ class OperatorService:
|
|
|
318
607
|
return get_earliest_activation(validators)
|
|
319
608
|
except Exception:
|
|
320
609
|
return None
|
|
610
|
+
|
|
611
|
+
async def get_withdrawal_history(self, operator_id: int) -> list[WithdrawalEvent]:
|
|
612
|
+
"""Get withdrawal/claim history for an operator.
|
|
613
|
+
|
|
614
|
+
Returns list of WithdrawalEvent objects representing when rewards were claimed.
|
|
615
|
+
"""
|
|
616
|
+
try:
|
|
617
|
+
operator = await self.onchain.get_node_operator(operator_id)
|
|
618
|
+
events = await self.onchain.get_withdrawal_history(operator.reward_address)
|
|
619
|
+
return [
|
|
620
|
+
WithdrawalEvent(
|
|
621
|
+
block_number=e["block_number"],
|
|
622
|
+
timestamp=e["timestamp"],
|
|
623
|
+
shares=e["shares"],
|
|
624
|
+
eth_value=e["eth_value"],
|
|
625
|
+
tx_hash=e["tx_hash"],
|
|
626
|
+
)
|
|
627
|
+
for e in events
|
|
628
|
+
]
|
|
629
|
+
except Exception:
|
|
630
|
+
return []
|
src/web/routes.py
CHANGED
|
@@ -11,6 +11,8 @@ router = APIRouter()
|
|
|
11
11
|
async def get_operator(
|
|
12
12
|
identifier: str,
|
|
13
13
|
detailed: bool = Query(False, description="Include validator status from beacon chain"),
|
|
14
|
+
history: bool = Query(False, description="Include all historical distribution frames"),
|
|
15
|
+
withdrawals: bool = Query(False, description="Include withdrawal/claim history"),
|
|
14
16
|
):
|
|
15
17
|
"""
|
|
16
18
|
Get operator data by address or ID.
|
|
@@ -18,6 +20,8 @@ async def get_operator(
|
|
|
18
20
|
- If identifier is numeric, treat as operator ID
|
|
19
21
|
- If identifier starts with 0x, treat as Ethereum address
|
|
20
22
|
- Add ?detailed=true to include validator status breakdown
|
|
23
|
+
- Add ?history=true to include all historical distribution frames
|
|
24
|
+
- Add ?withdrawals=true to include withdrawal/claim history
|
|
21
25
|
"""
|
|
22
26
|
service = OperatorService()
|
|
23
27
|
|
|
@@ -26,9 +30,9 @@ async def get_operator(
|
|
|
26
30
|
operator_id = int(identifier)
|
|
27
31
|
if operator_id < 0 or operator_id > 1_000_000:
|
|
28
32
|
raise HTTPException(status_code=400, detail="Invalid operator ID")
|
|
29
|
-
rewards = await service.get_operator_by_id(operator_id, detailed)
|
|
33
|
+
rewards = await service.get_operator_by_id(operator_id, detailed or history, history, withdrawals)
|
|
30
34
|
elif identifier.startswith("0x"):
|
|
31
|
-
rewards = await service.get_operator_by_address(identifier, detailed)
|
|
35
|
+
rewards = await service.get_operator_by_address(identifier, detailed or history, history, withdrawals)
|
|
32
36
|
else:
|
|
33
37
|
raise HTTPException(status_code=400, detail="Invalid identifier format")
|
|
34
38
|
|
|
@@ -39,6 +43,8 @@ async def get_operator(
|
|
|
39
43
|
"operator_id": rewards.node_operator_id,
|
|
40
44
|
"manager_address": rewards.manager_address,
|
|
41
45
|
"reward_address": rewards.reward_address,
|
|
46
|
+
"curve_id": rewards.curve_id,
|
|
47
|
+
"operator_type": rewards.operator_type,
|
|
42
48
|
"rewards": {
|
|
43
49
|
"current_bond_eth": float(rewards.current_bond_eth),
|
|
44
50
|
"required_bond_eth": float(rewards.required_bond_eth),
|
|
@@ -79,13 +85,65 @@ async def get_operator(
|
|
|
79
85
|
|
|
80
86
|
# Add APY metrics if available
|
|
81
87
|
if rewards.apy:
|
|
88
|
+
# Use actual excess bond for lifetime values (estimates for previous/current)
|
|
89
|
+
lifetime_bond = float(rewards.excess_bond_eth)
|
|
90
|
+
lifetime_net_total = (rewards.apy.lifetime_distribution_eth or 0) + lifetime_bond
|
|
82
91
|
result["apy"] = {
|
|
92
|
+
"previous_distribution_eth": rewards.apy.previous_distribution_eth,
|
|
93
|
+
"previous_distribution_apy": rewards.apy.previous_distribution_apy,
|
|
94
|
+
"previous_net_apy": rewards.apy.previous_net_apy,
|
|
95
|
+
"previous_bond_eth": rewards.apy.previous_bond_eth,
|
|
96
|
+
"previous_bond_apr": rewards.apy.previous_bond_apr,
|
|
97
|
+
"previous_net_total_eth": rewards.apy.previous_net_total_eth,
|
|
98
|
+
"current_distribution_eth": rewards.apy.current_distribution_eth,
|
|
99
|
+
"current_distribution_apy": rewards.apy.current_distribution_apy,
|
|
100
|
+
"current_bond_eth": rewards.apy.current_bond_eth,
|
|
101
|
+
"current_bond_apr": rewards.apy.current_bond_apr,
|
|
102
|
+
"current_net_total_eth": rewards.apy.current_net_total_eth,
|
|
103
|
+
"lifetime_distribution_eth": rewards.apy.lifetime_distribution_eth,
|
|
104
|
+
"lifetime_bond_eth": lifetime_bond, # Actual excess bond, not estimate
|
|
105
|
+
"lifetime_net_total_eth": lifetime_net_total, # Matches Total Claimable
|
|
106
|
+
# Accurate lifetime APY (per-frame bond calculation when available)
|
|
107
|
+
"lifetime_reward_apy": rewards.apy.lifetime_reward_apy,
|
|
108
|
+
"lifetime_bond_apy": rewards.apy.lifetime_bond_apy,
|
|
109
|
+
"lifetime_net_apy": rewards.apy.lifetime_net_apy,
|
|
110
|
+
"next_distribution_date": rewards.apy.next_distribution_date,
|
|
111
|
+
"next_distribution_est_eth": rewards.apy.next_distribution_est_eth,
|
|
83
112
|
"historical_reward_apy_28d": rewards.apy.historical_reward_apy_28d,
|
|
84
113
|
"historical_reward_apy_ltd": rewards.apy.historical_reward_apy_ltd,
|
|
85
114
|
"bond_apy": rewards.apy.bond_apy,
|
|
86
115
|
"net_apy_28d": rewards.apy.net_apy_28d,
|
|
87
116
|
"net_apy_ltd": rewards.apy.net_apy_ltd,
|
|
117
|
+
"uses_historical_apr": rewards.apy.uses_historical_apr,
|
|
88
118
|
}
|
|
119
|
+
# Add frames if available (from history=true)
|
|
120
|
+
if rewards.apy.frames:
|
|
121
|
+
result["apy"]["frames"] = [
|
|
122
|
+
{
|
|
123
|
+
"frame_number": f.frame_number,
|
|
124
|
+
"start_date": f.start_date,
|
|
125
|
+
"end_date": f.end_date,
|
|
126
|
+
"rewards_eth": f.rewards_eth,
|
|
127
|
+
"rewards_shares": f.rewards_shares,
|
|
128
|
+
"duration_days": f.duration_days,
|
|
129
|
+
"validator_count": f.validator_count,
|
|
130
|
+
"apy": f.apy,
|
|
131
|
+
}
|
|
132
|
+
for f in rewards.apy.frames
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# Add withdrawal history if withdrawals=true (already fetched during data gathering)
|
|
136
|
+
if withdrawals and rewards.withdrawals:
|
|
137
|
+
result["withdrawals"] = [
|
|
138
|
+
{
|
|
139
|
+
"block_number": w.block_number,
|
|
140
|
+
"timestamp": w.timestamp,
|
|
141
|
+
"shares": w.shares,
|
|
142
|
+
"eth_value": w.eth_value,
|
|
143
|
+
"tx_hash": w.tx_hash,
|
|
144
|
+
}
|
|
145
|
+
for w in rewards.withdrawals
|
|
146
|
+
]
|
|
89
147
|
|
|
90
148
|
# Add active_since if available
|
|
91
149
|
if rewards.active_since:
|
|
File without changes
|
|
File without changes
|