csm-dashboard 0.2.1__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.
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