csm-dashboard 0.3.2__py3-none-any.whl → 0.3.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csm-dashboard
3
- Version: 0.3.2
3
+ Version: 0.3.5
4
4
  Summary: Lido CSM Operator Dashboard for tracking validator earnings
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.104
@@ -3,30 +3,31 @@ src/main.py,sha256=tj7C09FVBGBVyjKYwmElpX5M92xfydDm8RJ6-MSFdMk,951
3
3
  src/abis/CSAccounting.json,sha256=-eBMqw3XqgMDzlVrG8mOrd7IYLf8nfsBIpjYqaKPYno,1281
4
4
  src/abis/CSFeeDistributor.json,sha256=unLBacJcCHq4xsmB4xOPlVXcOrxGWNf6KDmC3Ld5u-c,1517
5
5
  src/abis/CSModule.json,sha256=T6D6aInBoqVH3ZD6U6p3lrPa3t_ucA9V83IwE80kOuU,1687
6
+ src/abis/WithdrawalQueueERC721.json,sha256=b25a5RQW5HGPH_KQcolecVToHTgYwqeikz0ZEZ9MGxU,1424
6
7
  src/abis/__init__.py,sha256=9HV2hKMGSoNAi8evsjzymTr4V4kowITNsX1-LPu6l98,20
7
8
  src/abis/stETH.json,sha256=ldxbIRrtt8ePVFewJ9Tnz4qUGFmuOXa41GN1t3tnWEg,1106
8
9
  src/cli/__init__.py,sha256=mgHAwomqzAhOHJnlWrWtCguhGzhDWlkCvkzKsfoFsds,35
9
- src/cli/commands.py,sha256=B8-TUAPyQPanTGMd_nom6eo-PhnshrEiJD0NwLkdsbw,34954
10
+ src/cli/commands.py,sha256=JNbn74H3nbwk5IT515OB4Muw-edCG9m7imub16geEyA,37506
10
11
  src/core/__init__.py,sha256=ZDHZojANK1ZFpn5lQROERTo97MYQFAxqA9APvs7ruEQ,57
11
- src/core/config.py,sha256=NqkS2zsWyDMDz-q0eE9akHmTE9kZ-snGdTxLn-Dks4E,1532
12
- src/core/contracts.py,sha256=8Y72h5uUTaIMLWYdtu5O2Bjw1hTyIHOegaDs0W8r74g,525
13
- src/core/types.py,sha256=ymHofuF5kkZDQpi-RyO4ZRHGJqz-_fw21lNRY-bnefA,7311
12
+ src/core/config.py,sha256=KYYlJv383RtHo_CpaCt2DM7yaTr3C267cY4T8_ZRiEc,1613
13
+ src/core/contracts.py,sha256=u1KW0z-9V21Zpf7qxGBvRy2Ffdo4YfGusUdT4K_ggP4,582
14
+ src/core/types.py,sha256=pirEDIR6csbGWWUH2s3RDCG2ce-oqgEvdsamAI-rurM,7807
14
15
  src/data/__init__.py,sha256=DItA8aEp8Lbr0uFlJVppMaTtVEEznoA1hkRpH-vfHhk,57
15
16
  src/data/beacon.py,sha256=9oaR7TO2WcP_D3jVTzcd9WE6NbF2WnqQDW_y5M8GsZ0,13511
16
17
  src/data/cache.py,sha256=w1iv4rM-FVgFlaSNYdQA3CfqyhZo9-gqbZwKmzKY0Ps,2134
17
- src/data/etherscan.py,sha256=UeCucGPd4m39yl13uogY7hCBm46Ge7nZQ96V3eoOmu4,5064
18
+ src/data/etherscan.py,sha256=JcO6H2dFFZFOV-qCaNhoJvyza2ymd2GhgmJo7vxvTPM,10938
18
19
  src/data/ipfs_logs.py,sha256=gXUTP9dmZ_e7gW6ouotJ1r_72ixq2q_32y2evYQf0DM,10709
19
20
  src/data/known_cids.py,sha256=onwTTNvAv4h0-3LZgLhqMlKzuNH2VhBqQGZ9fm8GyoE,1705
20
21
  src/data/lido_api.py,sha256=477-1vqlMAwLaisaa61T9AxJuSUV6W7NLg1cxvdmheY,4884
21
- src/data/onchain.py,sha256=3B2dL8PHQL2BkSySUYOq1jwPI3WkUTOFPbiVkECiiMU,16716
22
+ src/data/onchain.py,sha256=2eLGAI0URz1JuNZi2JmLWN-h_UDfkDlbT30rXpoqbts,25252
22
23
  src/data/rewards_tree.py,sha256=a-MO14b4COjOvy59FPd0jaf1dkgidlqCQKAFDinwRJU,1894
23
24
  src/data/strikes.py,sha256=b68exKntZpzjiEBftiI3AcvEclHp7vg5lsOiG2a7Kzo,9046
24
25
  src/services/__init__.py,sha256=MC7blFLAMazErCWuyYXvS6sO3uZm1z_RUOtnlIK0kSo,38
25
- src/services/operator_service.py,sha256=Q_Sc65T06IMHUAP_zgROGkZSbEyFPSL4B4ssVsFEUSg,29562
26
+ src/services/operator_service.py,sha256=xncq7Z3DDUcr8cohcehvL9nqaONRUPRuvjBliEGYNd8,29985
26
27
  src/web/__init__.py,sha256=iI2c5xxXmzsNxIetm0P2qE3uVsT-ClsMfzn620r5YTU,40
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,,
28
+ src/web/app.py,sha256=kaUm78m_ESREUgVKlnZpZhsunhKrMBA7BZc-aUvEZRA,47944
29
+ src/web/routes.py,sha256=UydCD7DMWBYv_lbQQjeHI82l8gLZkZ2lEy4OayPxNiU,10716
30
+ csm_dashboard-0.3.5.dist-info/METADATA,sha256=Siio7ENgz75pgWkowGWT5vtpwb8hB3XaR4gjMb1oBj4,12552
31
+ csm_dashboard-0.3.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
32
+ csm_dashboard-0.3.5.dist-info/entry_points.txt,sha256=P1Ul8ALIPBwDlVlXqTPuzJ64xxRpIJsYW8U73Tyjgtg,37
33
+ csm_dashboard-0.3.5.dist-info/RECORD,,
@@ -0,0 +1,47 @@
1
+ [
2
+ {
3
+ "name": "WithdrawalRequested",
4
+ "type": "event",
5
+ "anonymous": false,
6
+ "inputs": [
7
+ {"indexed": true, "name": "requestId", "type": "uint256"},
8
+ {"indexed": true, "name": "requestor", "type": "address"},
9
+ {"indexed": true, "name": "owner", "type": "address"},
10
+ {"indexed": false, "name": "amountOfStETH", "type": "uint256"},
11
+ {"indexed": false, "name": "amountOfShares", "type": "uint256"}
12
+ ]
13
+ },
14
+ {
15
+ "name": "WithdrawalClaimed",
16
+ "type": "event",
17
+ "anonymous": false,
18
+ "inputs": [
19
+ {"indexed": true, "name": "requestId", "type": "uint256"},
20
+ {"indexed": true, "name": "owner", "type": "address"},
21
+ {"indexed": true, "name": "receiver", "type": "address"},
22
+ {"indexed": false, "name": "amountOfETH", "type": "uint256"}
23
+ ]
24
+ },
25
+ {
26
+ "name": "getWithdrawalStatus",
27
+ "type": "function",
28
+ "stateMutability": "view",
29
+ "inputs": [
30
+ {"name": "_requestIds", "type": "uint256[]"}
31
+ ],
32
+ "outputs": [
33
+ {
34
+ "name": "statuses",
35
+ "type": "tuple[]",
36
+ "components": [
37
+ {"name": "amountOfStETH", "type": "uint256"},
38
+ {"name": "amountOfShares", "type": "uint256"},
39
+ {"name": "owner", "type": "address"},
40
+ {"name": "timestamp", "type": "uint256"},
41
+ {"name": "isFinalized", "type": "bool"},
42
+ {"name": "isClaimed", "type": "bool"}
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ ]
src/cli/commands.py CHANGED
@@ -582,7 +582,9 @@ def rewards(
582
582
  withdrawal_table = Table(title="Withdrawal History")
583
583
  withdrawal_table.add_column("#", style="cyan", justify="right")
584
584
  withdrawal_table.add_column("Date", style="white")
585
- withdrawal_table.add_column("Amount (stETH)", style="green", justify="right")
585
+ withdrawal_table.add_column("Type", style="magenta")
586
+ withdrawal_table.add_column("Amount", style="green", justify="right")
587
+ withdrawal_table.add_column("Status", style="yellow")
586
588
  withdrawal_table.add_column("Tx Hash", style="dim")
587
589
 
588
590
  for i, w in enumerate(rewards.withdrawals, 1):
@@ -592,16 +594,73 @@ def rewards(
592
594
  except (ValueError, TypeError):
593
595
  w_date = w.timestamp[:10] if w.timestamp else "--"
594
596
 
597
+ # Determine display values based on type
598
+ withdrawal_type = w.withdrawal_type if w.withdrawal_type else "stETH"
599
+
600
+ # For unstETH, show claimed ETH if available, otherwise requested stETH
601
+ if withdrawal_type == "unstETH" and w.claimed_eth is not None:
602
+ amount_str = f"{w.claimed_eth:.4f} ETH"
603
+ else:
604
+ amount_str = f"{w.eth_value:.4f} stETH"
605
+
606
+ # Status column for unstETH
607
+ if withdrawal_type == "unstETH" and w.status:
608
+ status_colors = {
609
+ "pending": "[yellow]Pending[/yellow]",
610
+ "finalized": "[blue]Ready[/blue]",
611
+ "claimed": "[green]Claimed[/green]",
612
+ "unknown": "[dim]Unknown[/dim]",
613
+ }
614
+ status_str = status_colors.get(w.status, w.status)
615
+ elif withdrawal_type != "unstETH":
616
+ status_str = "[green]Claimed[/green]"
617
+ else:
618
+ status_str = "--"
619
+
595
620
  withdrawal_table.add_row(
596
621
  str(i),
597
622
  w_date,
598
- f"{w.eth_value:.4f}",
623
+ withdrawal_type,
624
+ amount_str,
625
+ status_str,
599
626
  f"{w.tx_hash[:10]}..." if w.tx_hash else "--",
600
627
  )
601
628
 
602
629
  console.print(withdrawal_table)
630
+
631
+ # Show totals
632
+ steth_total = sum(
633
+ w.eth_value for w in rewards.withdrawals
634
+ if w.withdrawal_type != "unstETH"
635
+ )
636
+ unsteth_claimed_total = sum(
637
+ w.claimed_eth for w in rewards.withdrawals
638
+ if w.withdrawal_type == "unstETH" and w.claimed_eth is not None
639
+ )
640
+ if steth_total > 0 or unsteth_claimed_total > 0:
641
+ total_parts = []
642
+ if steth_total > 0:
643
+ total_parts.append(f"{steth_total:.4f} stETH")
644
+ if unsteth_claimed_total > 0:
645
+ total_parts.append(f"{unsteth_claimed_total:.4f} ETH")
646
+ console.print(f"[bold]Total claimed:[/bold] {' + '.join(total_parts)}")
603
647
  console.print()
604
648
 
649
+ # Show pending unstETH summary if any
650
+ pending_unsteth = [
651
+ w for w in rewards.withdrawals
652
+ if w.withdrawal_type == "unstETH"
653
+ and w.status in ("pending", "finalized")
654
+ ]
655
+ if pending_unsteth:
656
+ pending_total = sum(w.eth_value for w in pending_unsteth)
657
+ ready_count = sum(1 for w in pending_unsteth if w.status == "finalized")
658
+ console.print(
659
+ f"[yellow]Note: {len(pending_unsteth)} unstETH request(s) "
660
+ f"({ready_count} ready to claim) totaling ~{pending_total:.4f} stETH[/yellow]"
661
+ )
662
+ console.print()
663
+
605
664
 
606
665
  @app.command()
607
666
  def health(
src/core/config.py CHANGED
@@ -37,6 +37,7 @@ class Settings(BaseSettings):
37
37
  csfeedistributor_address: str = "0xD99CC66fEC647E68294C6477B40fC7E0F6F618D0"
38
38
  csstrikes_address: str = "0xaa328816027F2D32B9F56d190BC9Fa4A5C07637f"
39
39
  steth_address: str = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"
40
+ withdrawal_queue_address: str = "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1"
40
41
 
41
42
 
42
43
  @lru_cache
src/core/contracts.py CHANGED
@@ -16,3 +16,4 @@ CSMODULE_ABI = load_abi("CSModule")
16
16
  CSACCOUNTING_ABI = load_abi("CSAccounting")
17
17
  CSFEEDISTRIBUTOR_ABI = load_abi("CSFeeDistributor")
18
18
  STETH_ABI = load_abi("stETH")
19
+ WITHDRAWAL_QUEUE_ABI = load_abi("WithdrawalQueueERC721")
src/core/types.py CHANGED
@@ -70,6 +70,16 @@ class WithdrawalEvent(BaseModel):
70
70
  eth_value: float
71
71
  tx_hash: str
72
72
 
73
+ # Withdrawal type: "stETH" (direct transfer) or "unstETH" (withdrawal NFT)
74
+ withdrawal_type: str = "stETH"
75
+
76
+ # unstETH-specific fields (only populated for unstETH type)
77
+ request_id: int | None = None
78
+ status: str | None = None # "pending", "finalized", or "claimed"
79
+ claimed_eth: float | None = None # Actual ETH received when claimed
80
+ claim_tx_hash: str | None = None # Transaction where claim occurred
81
+ claim_timestamp: str | None = None # When the claim happened
82
+
73
83
 
74
84
  class APYMetrics(BaseModel):
75
85
  """APY calculations for an operator.
src/data/etherscan.py CHANGED
@@ -136,3 +136,162 @@ class EtherscanProvider:
136
136
  continue
137
137
 
138
138
  return sorted(results, key=lambda x: x["block"])
139
+
140
+ async def get_withdrawal_requested_events(
141
+ self,
142
+ contract_address: str,
143
+ requestor: str,
144
+ owner: str,
145
+ from_block: int,
146
+ to_block: str | int = "latest",
147
+ ) -> list[dict]:
148
+ """Query WithdrawalRequested events from Etherscan.
149
+
150
+ Args:
151
+ contract_address: WithdrawalQueue contract address
152
+ requestor: Address that requested the withdrawal (CSAccounting)
153
+ owner: Address that owns the unstETH NFT (reward_address)
154
+ from_block: Starting block
155
+ to_block: Ending block
156
+
157
+ Returns:
158
+ List of withdrawal request events
159
+ """
160
+ if not self.api_key:
161
+ return []
162
+
163
+ # Event: WithdrawalRequested(uint256 indexed requestId, address indexed requestor,
164
+ # address indexed owner, uint256 amountOfStETH, uint256 amountOfShares)
165
+ topic0 = (
166
+ "0x"
167
+ + Web3.keccak(
168
+ text="WithdrawalRequested(uint256,address,address,uint256,uint256)"
169
+ ).hex()
170
+ )
171
+ # topic1 is indexed requestId - not filtering on this
172
+ # topic2 is indexed 'requestor' address (padded to 32 bytes)
173
+ topic2 = "0x" + requestor.lower().replace("0x", "").zfill(64)
174
+ # topic3 is indexed 'owner' address (padded to 32 bytes)
175
+ topic3 = "0x" + owner.lower().replace("0x", "").zfill(64)
176
+
177
+ async with httpx.AsyncClient(timeout=30.0) as client:
178
+ response = await client.get(
179
+ self.BASE_URL,
180
+ params={
181
+ "chainid": 1,
182
+ "module": "logs",
183
+ "action": "getLogs",
184
+ "address": contract_address,
185
+ "topic0": topic0,
186
+ "topic2": topic2,
187
+ "topic3": topic3,
188
+ "topic0_2_opr": "and",
189
+ "topic2_3_opr": "and",
190
+ "fromBlock": from_block,
191
+ "toBlock": to_block,
192
+ "apikey": self.api_key,
193
+ },
194
+ )
195
+
196
+ data = response.json()
197
+ if data.get("status") != "1":
198
+ return []
199
+
200
+ results = []
201
+ for log in data.get("result", []):
202
+ try:
203
+ # requestId is topic1 (indexed)
204
+ request_id = int(log["topics"][1], 16)
205
+ # amountOfStETH and amountOfShares are in data field
206
+ raw_data = log["data"]
207
+ # Each uint256 is 64 hex chars (32 bytes)
208
+ amount_steth = int(raw_data[2:66], 16)
209
+ amount_shares = int(raw_data[66:130], 16)
210
+
211
+ results.append(
212
+ {
213
+ "request_id": request_id,
214
+ "block": int(log["blockNumber"], 16),
215
+ "tx_hash": log["transactionHash"],
216
+ "amount_steth": amount_steth,
217
+ "amount_shares": amount_shares,
218
+ }
219
+ )
220
+ except (ValueError, TypeError, IndexError):
221
+ continue
222
+
223
+ return sorted(results, key=lambda x: x["block"])
224
+
225
+ async def get_withdrawal_claimed_events(
226
+ self,
227
+ contract_address: str,
228
+ receiver: str,
229
+ from_block: int,
230
+ to_block: str | int = "latest",
231
+ ) -> list[dict]:
232
+ """Query WithdrawalClaimed events from Etherscan.
233
+
234
+ Args:
235
+ contract_address: WithdrawalQueue contract address
236
+ receiver: Address that received the ETH (reward_address)
237
+ from_block: Starting block
238
+ to_block: Ending block
239
+
240
+ Returns:
241
+ List of withdrawal claim events
242
+ """
243
+ if not self.api_key:
244
+ return []
245
+
246
+ # Event: WithdrawalClaimed(uint256 indexed requestId, address indexed owner,
247
+ # address indexed receiver, uint256 amountOfETH)
248
+ topic0 = (
249
+ "0x"
250
+ + Web3.keccak(
251
+ text="WithdrawalClaimed(uint256,address,address,uint256)"
252
+ ).hex()
253
+ )
254
+ # topic3 is indexed 'receiver' address (padded to 32 bytes)
255
+ topic3 = "0x" + receiver.lower().replace("0x", "").zfill(64)
256
+
257
+ async with httpx.AsyncClient(timeout=30.0) as client:
258
+ response = await client.get(
259
+ self.BASE_URL,
260
+ params={
261
+ "chainid": 1,
262
+ "module": "logs",
263
+ "action": "getLogs",
264
+ "address": contract_address,
265
+ "topic0": topic0,
266
+ "topic3": topic3,
267
+ "topic0_3_opr": "and",
268
+ "fromBlock": from_block,
269
+ "toBlock": to_block,
270
+ "apikey": self.api_key,
271
+ },
272
+ )
273
+
274
+ data = response.json()
275
+ if data.get("status") != "1":
276
+ return []
277
+
278
+ results = []
279
+ for log in data.get("result", []):
280
+ try:
281
+ # requestId is topic1 (indexed)
282
+ request_id = int(log["topics"][1], 16)
283
+ # amountOfETH is in data field
284
+ amount_eth = int(log["data"], 16) / 10**18
285
+
286
+ results.append(
287
+ {
288
+ "request_id": request_id,
289
+ "tx_hash": log["transactionHash"],
290
+ "amount_eth": amount_eth,
291
+ "block": int(log["blockNumber"], 16),
292
+ }
293
+ )
294
+ except (ValueError, TypeError, IndexError):
295
+ continue
296
+
297
+ return sorted(results, key=lambda x: x["block"])
src/data/onchain.py CHANGED
@@ -11,6 +11,7 @@ from ..core.contracts import (
11
11
  CSFEEDISTRIBUTOR_ABI,
12
12
  CSMODULE_ABI,
13
13
  STETH_ABI,
14
+ WITHDRAWAL_QUEUE_ABI,
14
15
  )
15
16
  from ..core.types import BondSummary, NodeOperator
16
17
  from .cache import cached
@@ -42,6 +43,10 @@ class OnChainDataProvider:
42
43
  address=Web3.to_checksum_address(self.settings.steth_address),
43
44
  abi=STETH_ABI,
44
45
  )
46
+ self.withdrawal_queue = self.w3.eth.contract(
47
+ address=Web3.to_checksum_address(self.settings.withdrawal_queue_address),
48
+ abi=WITHDRAWAL_QUEUE_ABI,
49
+ )
45
50
 
46
51
  @cached(ttl=60)
47
52
  async def get_node_operators_count(self) -> int:
@@ -337,23 +342,38 @@ class OnChainDataProvider:
337
342
  self, reward_address: str, start_block: int | None = None
338
343
  ) -> list[dict]:
339
344
  """
340
- Get withdrawal history for an operator's reward address.
345
+ Get complete withdrawal history for an operator's reward address.
341
346
 
342
- Queries stETH Transfer events from CSAccounting to the reward address.
343
- These represent when the operator claimed their rewards.
344
- (Note: Claims flow CSFeeDistributor -> CSAccounting -> reward_address)
347
+ Combines multiple withdrawal types:
348
+ 1. stETH Transfer events from CSAccounting (claimRewardsStETH)
349
+ 2. unstETH withdrawal requests/claims (claimRewardsUnstETH)
345
350
 
346
351
  Args:
347
352
  reward_address: The operator's reward address
348
353
  start_block: Starting block number (default: CSM deployment ~20873000)
349
354
 
350
355
  Returns:
351
- List of withdrawal events with block, tx_hash, shares, and timestamp
356
+ List of withdrawal events with block, tx_hash, shares, timestamp, and type
352
357
  """
353
358
  if start_block is None:
354
359
  start_block = 20873000 # CSM deployment block
355
360
 
356
361
  reward_address = Web3.to_checksum_address(reward_address)
362
+
363
+ # Fetch both stETH and unstETH withdrawals
364
+ steth_events = await self._get_steth_withdrawals(reward_address, start_block)
365
+ unsteth_events = await self._get_unsteth_withdrawals(reward_address, start_block)
366
+
367
+ # Combine and sort by block number
368
+ all_events = steth_events + unsteth_events
369
+ all_events.sort(key=lambda x: x["block_number"])
370
+
371
+ return all_events
372
+
373
+ async def _get_steth_withdrawals(
374
+ self, reward_address: str, start_block: int
375
+ ) -> list[dict]:
376
+ """Get stETH direct transfer withdrawals (claimRewardsStETH)."""
357
377
  csaccounting_address = self.settings.csaccounting_address
358
378
 
359
379
  # 1. Try Etherscan API first (most reliable)
@@ -366,18 +386,218 @@ class OnChainDataProvider:
366
386
  from_block=start_block,
367
387
  )
368
388
  if events:
369
- # Enrich with block timestamps
370
- return await self._enrich_withdrawal_events(events)
389
+ enriched = await self._enrich_withdrawal_events(events)
390
+ # Mark as stETH type
391
+ for e in enriched:
392
+ e["withdrawal_type"] = "stETH"
393
+ return enriched
371
394
 
372
395
  # 2. Try chunked RPC queries
373
396
  events = await self._query_transfer_events_chunked(
374
397
  csaccounting_address, reward_address, start_block
375
398
  )
376
399
  if events:
377
- return await self._enrich_withdrawal_events(events)
400
+ enriched = await self._enrich_withdrawal_events(events)
401
+ for e in enriched:
402
+ e["withdrawal_type"] = "stETH"
403
+ return enriched
378
404
 
379
405
  return []
380
406
 
407
+ async def _get_unsteth_withdrawals(
408
+ self, reward_address: str, start_block: int
409
+ ) -> list[dict]:
410
+ """Get unstETH withdrawal requests (claimRewardsUnstETH).
411
+
412
+ Queries WithdrawalRequested events where CSAccounting is the requestor
413
+ and the reward_address is the owner of the withdrawal NFT.
414
+ """
415
+ csaccounting_address = self.settings.csaccounting_address
416
+
417
+ # Try Etherscan API first
418
+ etherscan = EtherscanProvider()
419
+ if etherscan.is_available():
420
+ events = await etherscan.get_withdrawal_requested_events(
421
+ contract_address=self.settings.withdrawal_queue_address,
422
+ requestor=csaccounting_address,
423
+ owner=reward_address,
424
+ from_block=start_block,
425
+ )
426
+ if events:
427
+ return await self._enrich_unsteth_events(events, reward_address)
428
+
429
+ # Fallback to chunked RPC queries
430
+ events = await self._query_withdrawal_requested_chunked(
431
+ csaccounting_address, reward_address, start_block
432
+ )
433
+ if events:
434
+ return await self._enrich_unsteth_events(events, reward_address)
435
+
436
+ return []
437
+
438
+ async def _query_withdrawal_requested_chunked(
439
+ self,
440
+ requestor: str,
441
+ owner: str,
442
+ start_block: int,
443
+ chunk_size: int = 10000,
444
+ ) -> list[dict]:
445
+ """Query WithdrawalRequested events in chunks via RPC."""
446
+ current_block = self.w3.eth.block_number
447
+ all_events = []
448
+
449
+ requestor = Web3.to_checksum_address(requestor)
450
+ owner = Web3.to_checksum_address(owner)
451
+
452
+ for from_blk in range(start_block, current_block, chunk_size):
453
+ to_blk = min(from_blk + chunk_size - 1, current_block)
454
+ try:
455
+ events = self.withdrawal_queue.events.WithdrawalRequested.get_logs(
456
+ from_block=from_blk,
457
+ to_block=to_blk,
458
+ argument_filters={
459
+ "requestor": requestor,
460
+ "owner": owner,
461
+ },
462
+ )
463
+ for e in events:
464
+ all_events.append(
465
+ {
466
+ "request_id": e["args"]["requestId"],
467
+ "block": e["blockNumber"],
468
+ "tx_hash": e["transactionHash"].hex(),
469
+ "amount_steth": e["args"]["amountOfStETH"],
470
+ "amount_shares": e["args"]["amountOfShares"],
471
+ }
472
+ )
473
+ except Exception:
474
+ # If chunked queries fail, give up on this method
475
+ return []
476
+
477
+ return sorted(all_events, key=lambda x: x["block"])
478
+
479
+ async def _enrich_unsteth_events(
480
+ self, events: list[dict], reward_address: str
481
+ ) -> list[dict]:
482
+ """Add timestamps, status, and claim info to unstETH events."""
483
+ from datetime import datetime, timezone
484
+
485
+ if not events:
486
+ return []
487
+
488
+ # Get status for all request IDs
489
+ request_ids = [e["request_id"] for e in events]
490
+ try:
491
+ statuses = self.withdrawal_queue.functions.getWithdrawalStatus(
492
+ request_ids
493
+ ).call()
494
+ except Exception:
495
+ # If status query fails, set all as unknown
496
+ statuses = [None] * len(events)
497
+
498
+ # Get claim events for this address
499
+ claim_events = await self._get_withdrawal_claimed_events(reward_address)
500
+ claims_by_request = {c["request_id"]: c for c in claim_events}
501
+
502
+ enriched = []
503
+ for i, event in enumerate(events):
504
+ try:
505
+ # Get block timestamp
506
+ block = self.w3.eth.get_block(event["block"])
507
+ timestamp = datetime.fromtimestamp(
508
+ block["timestamp"], tz=timezone.utc
509
+ ).isoformat()
510
+
511
+ # Determine status from contract query
512
+ status = statuses[i] if i < len(statuses) and statuses[i] else None
513
+ if status:
514
+ if status[5]: # isClaimed
515
+ withdrawal_status = "claimed"
516
+ elif status[4]: # isFinalized
517
+ withdrawal_status = "finalized"
518
+ else:
519
+ withdrawal_status = "pending"
520
+ else:
521
+ withdrawal_status = "unknown"
522
+
523
+ # Convert shares to ETH for display
524
+ shares = event.get("amount_shares", event.get("value", 0))
525
+ eth_value = await self.shares_to_eth(shares)
526
+
527
+ enriched_event = {
528
+ "block_number": event["block"],
529
+ "timestamp": timestamp,
530
+ "shares": shares,
531
+ "eth_value": float(eth_value),
532
+ "tx_hash": event["tx_hash"],
533
+ "withdrawal_type": "unstETH",
534
+ "request_id": event["request_id"],
535
+ "status": withdrawal_status,
536
+ }
537
+
538
+ # Add claim info if claimed
539
+ claim = claims_by_request.get(event["request_id"])
540
+ if claim:
541
+ enriched_event["claimed_eth"] = claim["amount_eth"]
542
+ enriched_event["claim_tx_hash"] = claim["tx_hash"]
543
+ # Get claim timestamp
544
+ try:
545
+ claim_block = self.w3.eth.get_block(claim["block"])
546
+ enriched_event["claim_timestamp"] = datetime.fromtimestamp(
547
+ claim_block["timestamp"], tz=timezone.utc
548
+ ).isoformat()
549
+ except Exception:
550
+ pass
551
+
552
+ enriched.append(enriched_event)
553
+ except Exception:
554
+ continue
555
+
556
+ return enriched
557
+
558
+ async def _get_withdrawal_claimed_events(
559
+ self, receiver: str, start_block: int = 20873000
560
+ ) -> list[dict]:
561
+ """Get WithdrawalClaimed events for a receiver address."""
562
+ receiver = Web3.to_checksum_address(receiver)
563
+
564
+ # Try Etherscan first
565
+ etherscan = EtherscanProvider()
566
+ if etherscan.is_available():
567
+ events = await etherscan.get_withdrawal_claimed_events(
568
+ contract_address=self.settings.withdrawal_queue_address,
569
+ receiver=receiver,
570
+ from_block=start_block,
571
+ )
572
+ if events:
573
+ return events
574
+
575
+ # RPC fallback - query in chunks
576
+ current_block = self.w3.eth.block_number
577
+ all_events = []
578
+
579
+ for from_blk in range(start_block, current_block, 10000):
580
+ to_blk = min(from_blk + 9999, current_block)
581
+ try:
582
+ logs = self.withdrawal_queue.events.WithdrawalClaimed.get_logs(
583
+ from_block=from_blk,
584
+ to_block=to_blk,
585
+ argument_filters={"receiver": receiver},
586
+ )
587
+ for e in logs:
588
+ all_events.append(
589
+ {
590
+ "request_id": e["args"]["requestId"],
591
+ "tx_hash": e["transactionHash"].hex(),
592
+ "amount_eth": e["args"]["amountOfETH"] / 10**18,
593
+ "block": e["blockNumber"],
594
+ }
595
+ )
596
+ except Exception:
597
+ continue
598
+
599
+ return all_events
600
+
381
601
  async def _query_transfer_events_chunked(
382
602
  self,
383
603
  from_address: str,
@@ -620,6 +620,7 @@ class OperatorService:
620
620
  """Get withdrawal/claim history for an operator.
621
621
 
622
622
  Returns list of WithdrawalEvent objects representing when rewards were claimed.
623
+ Includes both stETH direct transfers and unstETH (withdrawal NFT) claims.
623
624
  """
624
625
  try:
625
626
  operator = await self.onchain.get_node_operator(operator_id)
@@ -631,6 +632,12 @@ class OperatorService:
631
632
  shares=e["shares"],
632
633
  eth_value=e["eth_value"],
633
634
  tx_hash=e["tx_hash"],
635
+ withdrawal_type=e.get("withdrawal_type", "stETH"),
636
+ request_id=e.get("request_id"),
637
+ status=e.get("status"),
638
+ claimed_eth=e.get("claimed_eth"),
639
+ claim_tx_hash=e.get("claim_tx_hash"),
640
+ claim_timestamp=e.get("claim_timestamp"),
634
641
  )
635
642
  for e in events
636
643
  ]
src/web/app.py CHANGED
@@ -1,7 +1,10 @@
1
1
  """FastAPI application factory."""
2
2
 
3
+ from pathlib import Path
4
+
3
5
  from fastapi import FastAPI
4
6
  from fastapi.responses import HTMLResponse
7
+ from fastapi.staticfiles import StaticFiles
5
8
 
6
9
  from .routes import router
7
10
 
@@ -11,11 +14,16 @@ def create_app() -> FastAPI:
11
14
  app = FastAPI(
12
15
  title="CSM Operator Dashboard",
13
16
  description="Track your Lido CSM validator earnings",
14
- version="0.1.0",
17
+ version="0.3.5",
15
18
  )
16
19
 
17
20
  app.include_router(router, prefix="/api")
18
21
 
22
+ # Mount static files for favicon and images
23
+ img_dir = Path(__file__).parent.parent.parent / "img"
24
+ if img_dir.exists():
25
+ app.mount("/img", StaticFiles(directory=str(img_dir)), name="img")
26
+
19
27
  @app.get("/", response_class=HTMLResponse)
20
28
  async def index():
21
29
  return """
@@ -23,6 +31,7 @@ def create_app() -> FastAPI:
23
31
  <html>
24
32
  <head>
25
33
  <title>CSM Operator Dashboard</title>
34
+ <link rel="icon" type="image/x-icon" href="/img/favicon.ico">
26
35
  <script src="https://cdn.tailwindcss.com"></script>
27
36
  </head>
28
37
  <body class="bg-gray-900 text-white min-h-screen p-8">
@@ -247,6 +256,83 @@ def create_app() -> FastAPI:
247
256
  </table>
248
257
  </div>
249
258
  <p class="mt-3 text-xs text-gray-500">*Bond APY uses current stETH rate</p>
259
+
260
+ <!-- Next Distribution -->
261
+ <div id="next-distribution" class="hidden mt-4 pt-4 border-t border-gray-700">
262
+ <h4 class="text-md font-semibold mb-2 text-blue-400">Next Distribution</h4>
263
+ <div class="flex justify-between text-sm">
264
+ <span class="text-gray-400">Estimated Date</span>
265
+ <span id="next-dist-date">--</span>
266
+ </div>
267
+ <div class="flex justify-between text-sm mt-1">
268
+ <span class="text-gray-400">Est. Rewards</span>
269
+ <span class="text-green-400">~<span id="next-dist-eth">--</span> stETH</span>
270
+ </div>
271
+ </div>
272
+ </div>
273
+
274
+ <!-- Distribution History Section -->
275
+ <div id="history-section" class="hidden mt-6 bg-gray-800 rounded-lg p-6">
276
+ <div class="flex justify-between items-center mb-4">
277
+ <h3 class="text-lg font-bold">Distribution History</h3>
278
+ <button id="load-history-btn" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700 text-sm font-medium">
279
+ Load History
280
+ </button>
281
+ </div>
282
+ <div id="history-loading" class="hidden">
283
+ <div class="flex items-center justify-center p-4">
284
+ <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
285
+ <span class="ml-2 text-gray-400 text-sm">Loading history...</span>
286
+ </div>
287
+ </div>
288
+ <div id="history-table" class="hidden overflow-x-auto">
289
+ <table class="w-full text-sm">
290
+ <thead>
291
+ <tr class="text-gray-400">
292
+ <th class="text-left py-2">#</th>
293
+ <th class="text-left py-2">Date Range</th>
294
+ <th class="text-right py-2">Rewards</th>
295
+ <th class="text-right py-2">Validators</th>
296
+ <th class="text-right py-2">Per Val</th>
297
+ </tr>
298
+ </thead>
299
+ <tbody id="history-tbody">
300
+ <!-- Populated by JavaScript -->
301
+ </tbody>
302
+ </table>
303
+ </div>
304
+ </div>
305
+
306
+ <!-- Withdrawal History Section -->
307
+ <div id="withdrawal-section" class="hidden mt-6 bg-gray-800 rounded-lg p-6">
308
+ <div class="flex justify-between items-center mb-4">
309
+ <h3 class="text-lg font-bold">Withdrawal History</h3>
310
+ <button id="load-withdrawals-btn" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700 text-sm font-medium">
311
+ Load Withdrawals
312
+ </button>
313
+ </div>
314
+ <div id="withdrawal-loading" class="hidden">
315
+ <div class="flex items-center justify-center p-4">
316
+ <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
317
+ <span class="ml-2 text-gray-400 text-sm">Loading withdrawals...</span>
318
+ </div>
319
+ </div>
320
+ <div id="withdrawal-table" class="hidden overflow-x-auto">
321
+ <table class="w-full text-sm">
322
+ <thead>
323
+ <tr class="text-gray-400">
324
+ <th class="text-left py-2">#</th>
325
+ <th class="text-left py-2">Date</th>
326
+ <th class="text-left py-2">Type</th>
327
+ <th class="text-right py-2">Amount</th>
328
+ <th class="text-left py-2">Status</th>
329
+ </tr>
330
+ </thead>
331
+ <tbody id="withdrawal-tbody">
332
+ <!-- Populated by JavaScript -->
333
+ </tbody>
334
+ </table>
335
+ </div>
250
336
  </div>
251
337
  </div>
252
338
  </div>
@@ -262,6 +348,17 @@ def create_app() -> FastAPI:
262
348
  const validatorStatus = document.getElementById('validator-status');
263
349
  const apySection = document.getElementById('apy-section');
264
350
  const healthSection = document.getElementById('health-section');
351
+ const historySection = document.getElementById('history-section');
352
+ const loadHistoryBtn = document.getElementById('load-history-btn');
353
+ const historyLoading = document.getElementById('history-loading');
354
+ const historyTable = document.getElementById('history-table');
355
+ const historyTbody = document.getElementById('history-tbody');
356
+ const nextDistribution = document.getElementById('next-distribution');
357
+ const withdrawalSection = document.getElementById('withdrawal-section');
358
+ const loadWithdrawalsBtn = document.getElementById('load-withdrawals-btn');
359
+ const withdrawalLoading = document.getElementById('withdrawal-loading');
360
+ const withdrawalTable = document.getElementById('withdrawal-table');
361
+ const withdrawalTbody = document.getElementById('withdrawal-tbody');
265
362
 
266
363
  function formatApy(val) {
267
364
  return val !== null && val !== undefined ? val.toFixed(2) + '%' : '--%';
@@ -280,6 +377,17 @@ def create_app() -> FastAPI:
280
377
  validatorStatus.classList.add('hidden');
281
378
  apySection.classList.add('hidden');
282
379
  healthSection.classList.add('hidden');
380
+ historySection.classList.add('hidden');
381
+ nextDistribution.classList.add('hidden');
382
+ historyTable.classList.add('hidden');
383
+ historyTbody.innerHTML = '';
384
+ historyLoaded = false;
385
+ loadHistoryBtn.textContent = 'Load History';
386
+ withdrawalSection.classList.add('hidden');
387
+ withdrawalTable.classList.add('hidden');
388
+ withdrawalTbody.innerHTML = '';
389
+ withdrawalsLoaded = false;
390
+ loadWithdrawalsBtn.textContent = 'Load Withdrawals';
283
391
  document.getElementById('active-since-row').classList.add('hidden');
284
392
  loadDetailsBtn.classList.remove('hidden');
285
393
  loadDetailsBtn.disabled = false;
@@ -394,7 +502,23 @@ def create_app() -> FastAPI:
394
502
  document.getElementById('net-apy-28d').textContent = formatApy(data.apy.net_apy_28d);
395
503
  document.getElementById('net-apy-ltd').textContent = formatApy(data.apy.net_apy_ltd);
396
504
 
505
+ // Show next distribution info if available
506
+ if (data.apy.next_distribution_date || data.apy.next_distribution_est_eth) {
507
+ if (data.apy.next_distribution_date) {
508
+ const nextDate = new Date(data.apy.next_distribution_date);
509
+ const options = { year: 'numeric', month: 'short', day: 'numeric' };
510
+ document.getElementById('next-dist-date').textContent = nextDate.toLocaleDateString('en-US', options);
511
+ }
512
+ if (data.apy.next_distribution_est_eth) {
513
+ document.getElementById('next-dist-eth').textContent = data.apy.next_distribution_est_eth.toFixed(4);
514
+ }
515
+ nextDistribution.classList.remove('hidden');
516
+ }
517
+
397
518
  apySection.classList.remove('hidden');
519
+ // Show history and withdrawal sections with toggles
520
+ historySection.classList.remove('hidden');
521
+ withdrawalSection.classList.remove('hidden');
398
522
  }
399
523
 
400
524
  // Display Active Since date if available
@@ -571,6 +695,163 @@ def create_app() -> FastAPI:
571
695
  isLoadingDetails = false;
572
696
  }
573
697
  });
698
+
699
+ // History button handler
700
+ let historyLoaded = false;
701
+ loadHistoryBtn.addEventListener('click', async () => {
702
+ if (historyLoaded) {
703
+ // Toggle visibility
704
+ historyTable.classList.toggle('hidden');
705
+ loadHistoryBtn.textContent = historyTable.classList.contains('hidden')
706
+ ? 'Load History' : 'Hide History';
707
+ return;
708
+ }
709
+
710
+ const operatorId = document.getElementById('operator-id').textContent;
711
+ historyLoading.classList.remove('hidden');
712
+ historyTable.classList.add('hidden');
713
+
714
+ try {
715
+ const response = await fetch(`/api/operator/${operatorId}?detailed=true&history=true`);
716
+ const data = await response.json();
717
+
718
+ historyLoading.classList.add('hidden');
719
+
720
+ if (!response.ok || !data.apy || !data.apy.frames) {
721
+ historyTbody.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-gray-400">No history available</td></tr>';
722
+ historyTable.classList.remove('hidden');
723
+ return;
724
+ }
725
+
726
+ // Populate history table
727
+ historyTbody.innerHTML = data.apy.frames.map(frame => {
728
+ const startDate = new Date(frame.start_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
729
+ const endDate = new Date(frame.end_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
730
+ const perVal = frame.validator_count > 0 ? (frame.rewards_eth / frame.validator_count).toFixed(6) : '--';
731
+ return `<tr class="border-t border-gray-700">
732
+ <td class="py-2">${frame.frame_number}</td>
733
+ <td class="py-2">${startDate} - ${endDate}</td>
734
+ <td class="py-2 text-right text-green-400">${frame.rewards_eth.toFixed(4)}</td>
735
+ <td class="py-2 text-right">${frame.validator_count}</td>
736
+ <td class="py-2 text-right text-gray-400">${perVal}</td>
737
+ </tr>`;
738
+ }).join('');
739
+
740
+ // Add total row
741
+ const totalEth = data.apy.frames.reduce((sum, f) => sum + f.rewards_eth, 0);
742
+ historyTbody.innerHTML += `<tr class="border-t-2 border-gray-600 font-bold">
743
+ <td class="py-2" colspan="2">Total</td>
744
+ <td class="py-2 text-right text-yellow-400">${totalEth.toFixed(4)}</td>
745
+ <td class="py-2 text-right">--</td>
746
+ <td class="py-2 text-right">--</td>
747
+ </tr>`;
748
+
749
+ historyTable.classList.remove('hidden');
750
+ historyLoaded = true;
751
+ loadHistoryBtn.textContent = 'Hide History';
752
+ } catch (err) {
753
+ historyLoading.classList.add('hidden');
754
+ historyTbody.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-red-400">Failed to load history</td></tr>';
755
+ historyTable.classList.remove('hidden');
756
+ }
757
+ });
758
+
759
+ // Withdrawal button handler
760
+ let withdrawalsLoaded = false;
761
+ loadWithdrawalsBtn.addEventListener('click', async () => {
762
+ if (withdrawalsLoaded) {
763
+ // Toggle visibility
764
+ withdrawalTable.classList.toggle('hidden');
765
+ loadWithdrawalsBtn.textContent = withdrawalTable.classList.contains('hidden')
766
+ ? 'Load Withdrawals' : 'Hide Withdrawals';
767
+ return;
768
+ }
769
+
770
+ const operatorId = document.getElementById('operator-id').textContent;
771
+ withdrawalLoading.classList.remove('hidden');
772
+ withdrawalTable.classList.add('hidden');
773
+
774
+ try {
775
+ const response = await fetch(`/api/operator/${operatorId}?withdrawals=true`);
776
+ const data = await response.json();
777
+
778
+ withdrawalLoading.classList.add('hidden');
779
+
780
+ if (!response.ok || !data.withdrawals || data.withdrawals.length === 0) {
781
+ withdrawalTbody.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-gray-400">No withdrawals found</td></tr>';
782
+ withdrawalTable.classList.remove('hidden');
783
+ withdrawalsLoaded = true;
784
+ loadWithdrawalsBtn.textContent = 'Hide Withdrawals';
785
+ return;
786
+ }
787
+
788
+ // Populate withdrawal table
789
+ withdrawalTbody.innerHTML = data.withdrawals.map((w, i) => {
790
+ const date = new Date(w.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
791
+ const wType = w.withdrawal_type || 'stETH';
792
+ // For unstETH, show claimed ETH if available, otherwise show stETH value
793
+ let amount, amountClass;
794
+ if (wType === 'unstETH' && w.claimed_eth !== null) {
795
+ amount = w.claimed_eth.toFixed(4) + ' ETH';
796
+ amountClass = 'text-green-400';
797
+ } else {
798
+ amount = w.eth_value.toFixed(4) + ' stETH';
799
+ amountClass = 'text-green-400';
800
+ }
801
+ // Status for unstETH
802
+ let status;
803
+ if (wType === 'unstETH' && w.status) {
804
+ const statusColors = {
805
+ 'pending': 'text-yellow-400',
806
+ 'finalized': 'text-blue-400',
807
+ 'claimed': 'text-green-400',
808
+ };
809
+ const statusLabels = {
810
+ 'pending': 'Pending',
811
+ 'finalized': 'Ready',
812
+ 'claimed': 'Claimed',
813
+ };
814
+ status = `<span class="${statusColors[w.status] || 'text-gray-400'}">${statusLabels[w.status] || w.status}</span>`;
815
+ } else if (wType !== 'unstETH') {
816
+ status = '<span class="text-green-400">Claimed</span>';
817
+ } else {
818
+ status = '--';
819
+ }
820
+ return `<tr class="border-t border-gray-700">
821
+ <td class="py-2">${i + 1}</td>
822
+ <td class="py-2">${date}</td>
823
+ <td class="py-2"><span class="${wType === 'unstETH' ? 'text-purple-400' : 'text-blue-400'}">${wType}</span></td>
824
+ <td class="py-2 text-right ${amountClass}">${amount}</td>
825
+ <td class="py-2">${status}</td>
826
+ </tr>`;
827
+ }).join('');
828
+
829
+ // Add total row
830
+ const stethTotal = data.withdrawals
831
+ .filter(w => w.withdrawal_type !== 'unstETH')
832
+ .reduce((sum, w) => sum + w.eth_value, 0);
833
+ const ethTotal = data.withdrawals
834
+ .filter(w => w.withdrawal_type === 'unstETH' && w.claimed_eth !== null)
835
+ .reduce((sum, w) => sum + w.claimed_eth, 0);
836
+ let totalStr = '';
837
+ if (stethTotal > 0) totalStr += stethTotal.toFixed(4) + ' stETH';
838
+ if (ethTotal > 0) totalStr += (totalStr ? ' + ' : '') + ethTotal.toFixed(4) + ' ETH';
839
+ if (!totalStr) totalStr = '0';
840
+
841
+ withdrawalTbody.innerHTML += `<tr class="border-t-2 border-gray-600 font-bold">
842
+ <td class="py-2" colspan="3">Total Claimed</td>
843
+ <td class="py-2 text-right text-yellow-400" colspan="2">${totalStr}</td>
844
+ </tr>`;
845
+
846
+ withdrawalTable.classList.remove('hidden');
847
+ withdrawalsLoaded = true;
848
+ loadWithdrawalsBtn.textContent = 'Hide Withdrawals';
849
+ } catch (err) {
850
+ withdrawalLoading.classList.add('hidden');
851
+ withdrawalTbody.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-red-400">Failed to load withdrawals</td></tr>';
852
+ withdrawalTable.classList.remove('hidden');
853
+ }
854
+ });
574
855
  </script>
575
856
  </body>
576
857
  </html>
src/web/routes.py CHANGED
@@ -141,10 +141,29 @@ async def get_operator(
141
141
  "shares": w.shares,
142
142
  "eth_value": w.eth_value,
143
143
  "tx_hash": w.tx_hash,
144
+ "withdrawal_type": w.withdrawal_type,
145
+ "request_id": w.request_id,
146
+ "status": w.status,
147
+ "claimed_eth": w.claimed_eth,
148
+ "claim_tx_hash": w.claim_tx_hash,
149
+ "claim_timestamp": w.claim_timestamp,
144
150
  }
145
151
  for w in rewards.withdrawals
146
152
  ]
147
153
 
154
+ # Add summary for pending unstETH requests
155
+ pending_unsteth = [
156
+ w for w in rewards.withdrawals
157
+ if w.withdrawal_type == "unstETH"
158
+ and w.status in ("pending", "finalized")
159
+ ]
160
+ if pending_unsteth:
161
+ result["pending_unsteth"] = {
162
+ "count": len(pending_unsteth),
163
+ "ready_to_claim": sum(1 for w in pending_unsteth if w.status == "finalized"),
164
+ "total_steth_value": sum(w.eth_value for w in pending_unsteth),
165
+ }
166
+
148
167
  # Add active_since if available
149
168
  if rewards.active_since:
150
169
  result["active_since"] = rewards.active_since.isoformat()