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/data/lido_api.py
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import httpx
|
|
4
4
|
|
|
5
|
+
from ..core.config import get_settings
|
|
5
6
|
from .cache import cached
|
|
6
7
|
|
|
7
8
|
LIDO_API_BASE = "https://eth-api.lido.fi/v1"
|
|
9
|
+
LIDO_SUBGRAPH_ID = "Sxx812XgeKyzQPaBpR5YZWmGV5fZuBaPdh7DFhzSwiQ"
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class LidoAPIProvider:
|
|
@@ -33,3 +35,106 @@ class LidoAPIProvider:
|
|
|
33
35
|
pass
|
|
34
36
|
|
|
35
37
|
return {"apr": None, "timestamp": None}
|
|
38
|
+
|
|
39
|
+
@cached(ttl=3600) # Cache for 1 hour
|
|
40
|
+
async def get_historical_apr_data(self) -> list[dict]:
|
|
41
|
+
"""Fetch historical APR data from Lido subgraph.
|
|
42
|
+
|
|
43
|
+
Returns list of {block, apr, blockTime} sorted by block ascending.
|
|
44
|
+
Returns empty list if API key not configured or query fails.
|
|
45
|
+
"""
|
|
46
|
+
settings = get_settings()
|
|
47
|
+
if not settings.thegraph_api_key:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
# Query in descending order to get most recent 1000 entries
|
|
51
|
+
# (CSM frames are at blocks 21M+, we need recent data)
|
|
52
|
+
query = """
|
|
53
|
+
{
|
|
54
|
+
totalRewards(first: 1000, orderBy: block, orderDirection: desc) {
|
|
55
|
+
apr
|
|
56
|
+
block
|
|
57
|
+
blockTime
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
endpoint = f"https://gateway-arbitrum.network.thegraph.com/api/{settings.thegraph_api_key}/subgraphs/id/{LIDO_SUBGRAPH_ID}"
|
|
63
|
+
|
|
64
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
65
|
+
try:
|
|
66
|
+
response = await client.post(
|
|
67
|
+
endpoint,
|
|
68
|
+
json={"query": query},
|
|
69
|
+
headers={"Content-Type": "application/json"},
|
|
70
|
+
)
|
|
71
|
+
if response.status_code == 200:
|
|
72
|
+
data = response.json()
|
|
73
|
+
results = data.get("data", {}).get("totalRewards", [])
|
|
74
|
+
# Reverse to get ascending order (oldest to newest) for binary search
|
|
75
|
+
return list(reversed(results))
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
def get_apr_for_block(self, apr_data: list[dict], target_block: int) -> float | None:
|
|
82
|
+
"""Find the APR for a specific block number.
|
|
83
|
+
|
|
84
|
+
Returns the APR from the oracle report closest to (but not after) target_block.
|
|
85
|
+
"""
|
|
86
|
+
if not apr_data:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Find the closest report at or before target_block
|
|
90
|
+
closest = None
|
|
91
|
+
for entry in apr_data:
|
|
92
|
+
block = int(entry["block"])
|
|
93
|
+
if block <= target_block:
|
|
94
|
+
closest = entry
|
|
95
|
+
else:
|
|
96
|
+
break # apr_data is sorted ascending
|
|
97
|
+
|
|
98
|
+
return float(closest["apr"]) if closest else None
|
|
99
|
+
|
|
100
|
+
def get_average_apr_for_range(
|
|
101
|
+
self, apr_data: list[dict], start_timestamp: int, end_timestamp: int
|
|
102
|
+
) -> float | None:
|
|
103
|
+
"""Calculate average APR for a time range.
|
|
104
|
+
|
|
105
|
+
Averages all APR values from oracle reports within the given timestamp range.
|
|
106
|
+
Falls back to the closest APR before the range if no reports fall within.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
apr_data: List of {block, apr, blockTime} sorted by block ascending
|
|
110
|
+
start_timestamp: Unix timestamp for range start
|
|
111
|
+
end_timestamp: Unix timestamp for range end
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Average APR as a percentage, or None if no data available
|
|
115
|
+
"""
|
|
116
|
+
if not apr_data:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# Find all APR reports within the time range
|
|
120
|
+
reports_in_range = []
|
|
121
|
+
closest_before = None
|
|
122
|
+
|
|
123
|
+
for entry in apr_data:
|
|
124
|
+
block_time = int(entry["blockTime"])
|
|
125
|
+
if block_time < start_timestamp:
|
|
126
|
+
closest_before = entry # Keep track of most recent before range
|
|
127
|
+
elif block_time <= end_timestamp:
|
|
128
|
+
reports_in_range.append(entry)
|
|
129
|
+
else:
|
|
130
|
+
break # Past the range, stop searching
|
|
131
|
+
|
|
132
|
+
if reports_in_range:
|
|
133
|
+
# Average all reports within the range
|
|
134
|
+
total_apr = sum(float(r["apr"]) for r in reports_in_range)
|
|
135
|
+
return total_apr / len(reports_in_range)
|
|
136
|
+
elif closest_before:
|
|
137
|
+
# No reports in range, use the closest one before
|
|
138
|
+
return float(closest_before["apr"])
|
|
139
|
+
|
|
140
|
+
return None
|
src/data/onchain.py
CHANGED
|
@@ -122,6 +122,81 @@ class OnChainDataProvider:
|
|
|
122
122
|
|
|
123
123
|
return None
|
|
124
124
|
|
|
125
|
+
@cached(ttl=300)
|
|
126
|
+
async def get_bond_curve_id(self, operator_id: int) -> int:
|
|
127
|
+
"""Get the bond curve ID for an operator.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
0 = Permissionless (2.4 ETH first validator, 1.3 ETH subsequent)
|
|
131
|
+
1 = ICS/Legacy EA (1.5 ETH first validator, 1.3 ETH subsequent)
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
return self.csaccounting.functions.getBondCurveId(operator_id).call()
|
|
135
|
+
except Exception:
|
|
136
|
+
# Fall back to 0 (Permissionless) if call fails
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def calculate_required_bond(validator_count: int, curve_id: int = 0) -> Decimal:
|
|
141
|
+
"""Calculate required bond for a given validator count and curve type.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
validator_count: Number of validators
|
|
145
|
+
curve_id: Bond curve ID from CSAccounting contract
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Required bond in ETH
|
|
149
|
+
|
|
150
|
+
Note:
|
|
151
|
+
Curve IDs on mainnet CSM:
|
|
152
|
+
- Curve 0: Original permissionless (2 ETH first, 1.3 ETH subsequent) - deprecated
|
|
153
|
+
- Curve 1: Original ICS/EA (1.5 ETH first, 1.3 ETH subsequent) - deprecated
|
|
154
|
+
- Curve 2: Current permissionless default (1.5 ETH first, 1.3 ETH subsequent)
|
|
155
|
+
The contract returns curve points directly, but for estimation we use
|
|
156
|
+
standard formulas.
|
|
157
|
+
"""
|
|
158
|
+
if validator_count <= 0:
|
|
159
|
+
return Decimal(0)
|
|
160
|
+
|
|
161
|
+
# Curve 2 is the current mainnet default (1.5 ETH first, 1.3 ETH subsequent)
|
|
162
|
+
# Curve 0/1 were the original curves, now deprecated
|
|
163
|
+
if curve_id == 0: # Original Permissionless (deprecated)
|
|
164
|
+
first_bond = Decimal("2.0")
|
|
165
|
+
else: # Curve 1, 2, etc - current default curves
|
|
166
|
+
first_bond = Decimal("1.5")
|
|
167
|
+
|
|
168
|
+
subsequent_bond = Decimal("1.3")
|
|
169
|
+
|
|
170
|
+
if validator_count == 1:
|
|
171
|
+
return first_bond
|
|
172
|
+
else:
|
|
173
|
+
return first_bond + (subsequent_bond * (validator_count - 1))
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def get_operator_type_name(curve_id: int) -> str:
|
|
177
|
+
"""Get human-readable operator type from curve ID.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
curve_id: Bond curve ID from CSAccounting contract
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Operator type name
|
|
184
|
+
|
|
185
|
+
Note:
|
|
186
|
+
Curve IDs on mainnet CSM:
|
|
187
|
+
- Curve 0: Original permissionless (deprecated)
|
|
188
|
+
- Curve 1: Original ICS/EA (deprecated)
|
|
189
|
+
- Curve 2: Current permissionless default
|
|
190
|
+
"""
|
|
191
|
+
if curve_id == 0:
|
|
192
|
+
return "Permissionless (Legacy)"
|
|
193
|
+
elif curve_id == 1:
|
|
194
|
+
return "ICS/Legacy EA"
|
|
195
|
+
elif curve_id == 2:
|
|
196
|
+
return "Permissionless"
|
|
197
|
+
else:
|
|
198
|
+
return f"Custom (Curve {curve_id})"
|
|
199
|
+
|
|
125
200
|
@cached(ttl=60)
|
|
126
201
|
async def get_bond_summary(self, operator_id: int) -> BondSummary:
|
|
127
202
|
"""Get bond summary for an operator."""
|
|
@@ -256,3 +331,118 @@ class OnChainDataProvider:
|
|
|
256
331
|
return []
|
|
257
332
|
|
|
258
333
|
return sorted(all_events, key=lambda x: x["block"])
|
|
334
|
+
|
|
335
|
+
@cached(ttl=3600) # Cache for 1 hour
|
|
336
|
+
async def get_withdrawal_history(
|
|
337
|
+
self, reward_address: str, start_block: int | None = None
|
|
338
|
+
) -> list[dict]:
|
|
339
|
+
"""
|
|
340
|
+
Get withdrawal history for an operator's reward address.
|
|
341
|
+
|
|
342
|
+
Queries stETH Transfer events from CSFeeDistributor to the reward address.
|
|
343
|
+
These represent when the operator claimed their rewards.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
reward_address: The operator's reward address
|
|
347
|
+
start_block: Starting block number (default: CSM deployment ~20873000)
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
List of withdrawal events with block, tx_hash, shares, and timestamp
|
|
351
|
+
"""
|
|
352
|
+
if start_block is None:
|
|
353
|
+
start_block = 20873000 # CSM deployment block
|
|
354
|
+
|
|
355
|
+
reward_address = Web3.to_checksum_address(reward_address)
|
|
356
|
+
csfeedistributor_address = self.settings.csfeedistributor_address
|
|
357
|
+
|
|
358
|
+
# 1. Try Etherscan API first (most reliable)
|
|
359
|
+
etherscan = EtherscanProvider()
|
|
360
|
+
if etherscan.is_available():
|
|
361
|
+
events = await etherscan.get_transfer_events(
|
|
362
|
+
token_address=self.settings.steth_address,
|
|
363
|
+
from_address=csfeedistributor_address,
|
|
364
|
+
to_address=reward_address,
|
|
365
|
+
from_block=start_block,
|
|
366
|
+
)
|
|
367
|
+
if events:
|
|
368
|
+
# Enrich with block timestamps
|
|
369
|
+
return await self._enrich_withdrawal_events(events)
|
|
370
|
+
|
|
371
|
+
# 2. Try chunked RPC queries
|
|
372
|
+
events = await self._query_transfer_events_chunked(
|
|
373
|
+
csfeedistributor_address, reward_address, start_block
|
|
374
|
+
)
|
|
375
|
+
if events:
|
|
376
|
+
return await self._enrich_withdrawal_events(events)
|
|
377
|
+
|
|
378
|
+
return []
|
|
379
|
+
|
|
380
|
+
async def _query_transfer_events_chunked(
|
|
381
|
+
self,
|
|
382
|
+
from_address: str,
|
|
383
|
+
to_address: str,
|
|
384
|
+
start_block: int,
|
|
385
|
+
chunk_size: int = 10000,
|
|
386
|
+
) -> list[dict]:
|
|
387
|
+
"""Query Transfer events in smaller chunks."""
|
|
388
|
+
current_block = self.w3.eth.block_number
|
|
389
|
+
all_events = []
|
|
390
|
+
|
|
391
|
+
from_address = Web3.to_checksum_address(from_address)
|
|
392
|
+
to_address = Web3.to_checksum_address(to_address)
|
|
393
|
+
|
|
394
|
+
for from_blk in range(start_block, current_block, chunk_size):
|
|
395
|
+
to_blk = min(from_blk + chunk_size - 1, current_block)
|
|
396
|
+
try:
|
|
397
|
+
events = self.steth.events.Transfer.get_logs(
|
|
398
|
+
from_block=from_blk,
|
|
399
|
+
to_block=to_blk,
|
|
400
|
+
argument_filters={
|
|
401
|
+
"from": from_address,
|
|
402
|
+
"to": to_address,
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
for e in events:
|
|
406
|
+
all_events.append(
|
|
407
|
+
{
|
|
408
|
+
"block": e["blockNumber"],
|
|
409
|
+
"tx_hash": e["transactionHash"].hex(),
|
|
410
|
+
"value": e["args"]["value"],
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
except Exception:
|
|
414
|
+
# If chunked queries fail, give up on this method
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
return sorted(all_events, key=lambda x: x["block"])
|
|
418
|
+
|
|
419
|
+
async def _enrich_withdrawal_events(self, events: list[dict]) -> list[dict]:
|
|
420
|
+
"""Add timestamps and ETH values to withdrawal events."""
|
|
421
|
+
from datetime import datetime, timezone
|
|
422
|
+
|
|
423
|
+
enriched = []
|
|
424
|
+
for event in events:
|
|
425
|
+
try:
|
|
426
|
+
# Get block timestamp
|
|
427
|
+
block = self.w3.eth.get_block(event["block"])
|
|
428
|
+
timestamp = datetime.fromtimestamp(
|
|
429
|
+
block["timestamp"], tz=timezone.utc
|
|
430
|
+
).isoformat()
|
|
431
|
+
|
|
432
|
+
# Convert shares to ETH (using current rate as approximation)
|
|
433
|
+
eth_value = await self.shares_to_eth(event["value"])
|
|
434
|
+
|
|
435
|
+
enriched.append(
|
|
436
|
+
{
|
|
437
|
+
"block_number": event["block"],
|
|
438
|
+
"timestamp": timestamp,
|
|
439
|
+
"shares": event["value"],
|
|
440
|
+
"eth_value": float(eth_value),
|
|
441
|
+
"tx_hash": event["tx_hash"],
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
except Exception:
|
|
445
|
+
# Skip events we can't enrich
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
return enriched
|