csm-dashboard 0.3.2__py3-none-any.whl → 0.3.6__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.3.2.dist-info → csm_dashboard-0.3.6.dist-info}/METADATA +1 -1
- {csm_dashboard-0.3.2.dist-info → csm_dashboard-0.3.6.dist-info}/RECORD +15 -14
- src/abis/WithdrawalQueueERC721.json +47 -0
- src/cli/commands.py +61 -2
- src/core/config.py +1 -0
- src/core/contracts.py +1 -0
- src/core/types.py +10 -0
- src/data/beacon.py +60 -22
- src/data/etherscan.py +159 -0
- src/data/onchain.py +228 -8
- src/services/operator_service.py +7 -0
- src/web/app.py +282 -1
- src/web/routes.py +19 -0
- {csm_dashboard-0.3.2.dist-info → csm_dashboard-0.3.6.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.3.2.dist-info → csm_dashboard-0.3.6.dist-info}/entry_points.txt +0 -0
|
@@ -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=
|
|
10
|
+
src/cli/commands.py,sha256=JNbn74H3nbwk5IT515OB4Muw-edCG9m7imub16geEyA,37506
|
|
10
11
|
src/core/__init__.py,sha256=ZDHZojANK1ZFpn5lQROERTo97MYQFAxqA9APvs7ruEQ,57
|
|
11
|
-
src/core/config.py,sha256=
|
|
12
|
-
src/core/contracts.py,sha256=
|
|
13
|
-
src/core/types.py,sha256=
|
|
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
|
-
src/data/beacon.py,sha256=
|
|
16
|
+
src/data/beacon.py,sha256=jICauXdee4efYj8mj1GIoGsY5_uWmgHB57XhqkXvwS4,15439
|
|
16
17
|
src/data/cache.py,sha256=w1iv4rM-FVgFlaSNYdQA3CfqyhZo9-gqbZwKmzKY0Ps,2134
|
|
17
|
-
src/data/etherscan.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
28
|
-
src/web/routes.py,sha256=
|
|
29
|
-
csm_dashboard-0.3.
|
|
30
|
-
csm_dashboard-0.3.
|
|
31
|
-
csm_dashboard-0.3.
|
|
32
|
-
csm_dashboard-0.3.
|
|
28
|
+
src/web/app.py,sha256=AvNKmY6mNRDQaHQLDaLmf9LJxtcB0Mc-YG83XyJ7K9g,47944
|
|
29
|
+
src/web/routes.py,sha256=UydCD7DMWBYv_lbQQjeHI82l8gLZkZ2lEy4OayPxNiU,10716
|
|
30
|
+
csm_dashboard-0.3.6.dist-info/METADATA,sha256=7rgyMM56MfOPeTBY4M8TitFJOPMT70SuxqHMQhxGIwA,12552
|
|
31
|
+
csm_dashboard-0.3.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
32
|
+
csm_dashboard-0.3.6.dist-info/entry_points.txt,sha256=P1Ul8ALIPBwDlVlXqTPuzJ64xxRpIJsYW8U73Tyjgtg,37
|
|
33
|
+
csm_dashboard-0.3.6.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("
|
|
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
|
-
|
|
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
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/beacon.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Beacon chain data fetching via beaconcha.in API."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
from datetime import datetime, timedelta, timezone
|
|
4
5
|
from decimal import Decimal
|
|
5
6
|
from enum import Enum
|
|
@@ -172,47 +173,84 @@ class BeaconDataProvider:
|
|
|
172
173
|
Fetch validator info for multiple pubkeys.
|
|
173
174
|
|
|
174
175
|
beaconcha.in supports comma-separated pubkeys (up to 100).
|
|
176
|
+
Includes retry logic for rate limiting and proper error handling.
|
|
175
177
|
"""
|
|
176
178
|
if not pubkeys:
|
|
177
179
|
return []
|
|
178
180
|
|
|
179
181
|
validators = []
|
|
180
182
|
batch_size = 100 # beaconcha.in limit
|
|
183
|
+
max_retries = 3
|
|
181
184
|
|
|
182
185
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
183
186
|
for i in range(0, len(pubkeys), batch_size):
|
|
184
187
|
batch = pubkeys[i : i + batch_size]
|
|
185
188
|
pubkeys_param = ",".join(batch)
|
|
186
189
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
headers=self._get_headers(),
|
|
191
|
-
)
|
|
190
|
+
# Add delay between batches to avoid rate limiting
|
|
191
|
+
if i > 0:
|
|
192
|
+
await asyncio.sleep(0.5)
|
|
192
193
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
for attempt in range(max_retries):
|
|
195
|
+
try:
|
|
196
|
+
response = await client.get(
|
|
197
|
+
f"{self.base_url}/validator/{pubkeys_param}",
|
|
198
|
+
headers=self._get_headers(),
|
|
199
|
+
)
|
|
198
200
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
if response.status_code == 200:
|
|
202
|
+
data = response.json().get("data", [])
|
|
203
|
+
# API returns single object if only one validator
|
|
204
|
+
if isinstance(data, dict):
|
|
205
|
+
data = [data]
|
|
206
|
+
|
|
207
|
+
for v in data:
|
|
208
|
+
validators.append(self._parse_validator(v))
|
|
209
|
+
break # Success, exit retry loop
|
|
210
|
+
elif response.status_code == 404:
|
|
211
|
+
# Validators not found - create placeholder entries
|
|
212
|
+
for pubkey in batch:
|
|
213
|
+
validators.append(
|
|
214
|
+
ValidatorInfo(
|
|
215
|
+
pubkey=pubkey,
|
|
216
|
+
status=ValidatorStatus.PENDING_INITIALIZED,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
break # Success, exit retry loop
|
|
220
|
+
elif response.status_code == 429:
|
|
221
|
+
# Rate limited - wait and retry
|
|
222
|
+
if attempt < max_retries - 1:
|
|
223
|
+
await asyncio.sleep(2**attempt) # 1s, 2s, 4s
|
|
224
|
+
continue
|
|
225
|
+
# Max retries reached, add as unknown
|
|
226
|
+
for pubkey in batch:
|
|
227
|
+
validators.append(
|
|
228
|
+
ValidatorInfo(
|
|
229
|
+
pubkey=pubkey, status=ValidatorStatus.UNKNOWN
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
break
|
|
233
|
+
else:
|
|
234
|
+
# Other error status - add as unknown
|
|
235
|
+
for pubkey in batch:
|
|
236
|
+
validators.append(
|
|
237
|
+
ValidatorInfo(
|
|
238
|
+
pubkey=pubkey, status=ValidatorStatus.UNKNOWN
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
break
|
|
242
|
+
except Exception:
|
|
243
|
+
if attempt < max_retries - 1:
|
|
244
|
+
await asyncio.sleep(1)
|
|
245
|
+
continue
|
|
246
|
+
# On final failure, add unknown status for this batch
|
|
203
247
|
for pubkey in batch:
|
|
204
248
|
validators.append(
|
|
205
249
|
ValidatorInfo(
|
|
206
|
-
pubkey=pubkey,
|
|
207
|
-
status=ValidatorStatus.PENDING_INITIALIZED,
|
|
250
|
+
pubkey=pubkey, status=ValidatorStatus.UNKNOWN
|
|
208
251
|
)
|
|
209
252
|
)
|
|
210
|
-
|
|
211
|
-
# On error, add unknown status for this batch
|
|
212
|
-
for pubkey in batch:
|
|
213
|
-
validators.append(
|
|
214
|
-
ValidatorInfo(pubkey=pubkey, status=ValidatorStatus.UNKNOWN)
|
|
215
|
-
)
|
|
253
|
+
break
|
|
216
254
|
|
|
217
255
|
return validators
|
|
218
256
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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,
|
src/services/operator_service.py
CHANGED
|
@@ -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.
|
|
17
|
+
version="0.3.6",
|
|
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()
|
|
File without changes
|
|
File without changes
|