csm-dashboard 0.3.2__tar.gz → 0.3.6__tar.gz
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.6/CHANGELOG.md +30 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/PKG-INFO +1 -1
- csm_dashboard-0.3.6/img/favicon.ico +0 -0
- csm_dashboard-0.3.6/img/logo.png +0 -0
- csm_dashboard-0.3.6/my-lido-csm-dashboard.xml +32 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/pyproject.toml +1 -1
- csm_dashboard-0.3.6/src/abis/WithdrawalQueueERC721.json +47 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/cli/commands.py +61 -2
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/config.py +1 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/contracts.py +1 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/types.py +10 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/beacon.py +60 -22
- csm_dashboard-0.3.6/src/data/etherscan.py +297 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/onchain.py +228 -8
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/services/operator_service.py +7 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/web/app.py +282 -1
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/web/routes.py +19 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_config.py +1 -0
- csm_dashboard-0.3.2/img/logo.png +0 -0
- csm_dashboard-0.3.2/src/data/etherscan.py +0 -138
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.dockerignore +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.env.example +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.github/workflows/docker-publish.yaml +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.github/workflows/release.yaml +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.gitignore +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/Dockerfile +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/README.md +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/docker-compose.yml +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/img/csm-dash-cli.png +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/img/csm-dash-web.png +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/requirements.txt +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/CSAccounting.json +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/CSFeeDistributor.json +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/CSModule.json +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/stETH.json +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/cli/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/cache.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/ipfs_logs.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/known_cids.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/lido_api.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/rewards_tree.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/strikes.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/main.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/services/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/web/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/__init__.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/conftest.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_cache.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_strikes.py +0 -0
- {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_types.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.6] - 2026-01-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Retry logic and rate limiting for validator batch fetching
|
|
7
|
+
|
|
8
|
+
## [0.3.5] - 2026-01-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Web:** Withdrawal History section with Load/Hide button toggle
|
|
12
|
+
- **Web:** Distribution History section with Load/Hide button toggle
|
|
13
|
+
- **Web:** Next Distribution info (estimated date and rewards)
|
|
14
|
+
- **Web:** Favicon support
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **CLI/Web:** unstETH withdrawals now correctly show as "unstETH" type with ETH amounts
|
|
18
|
+
- **CLI:** Added total row to Withdrawal History table
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **Web:** History toggles changed from checkboxes to buttons for better UX
|
|
22
|
+
|
|
23
|
+
## [0.3.4] - 2025-12
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- unstETH (Lido Withdrawal NFT) tracking for `claimRewardsUnstETH` claims
|
|
27
|
+
- Withdrawal status tracking (Pending/Ready/Claimed)
|
|
28
|
+
|
|
29
|
+
## [0.3.3] and earlier
|
|
30
|
+
- See git history for previous changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<?xml version="1.0"?>
|
|
2
|
+
<Container version="2">
|
|
3
|
+
<Name>lido-csm-dashboard</Name>
|
|
4
|
+
<Repository>0xdespot/lido-csm-dashboard:latest</Repository>
|
|
5
|
+
<Registry>https://hub.docker.com/r/0xdespot/lido-csm-dashboard</Registry>
|
|
6
|
+
<Branch>
|
|
7
|
+
<Tag>latest</Tag>
|
|
8
|
+
<TagDescription>Latest stable release</TagDescription>
|
|
9
|
+
</Branch>
|
|
10
|
+
<Network>bridge</Network>
|
|
11
|
+
<Shell>sh</Shell>
|
|
12
|
+
<Privileged>false</Privileged>
|
|
13
|
+
<Support>https://github.com/0xdespot/lido-csm-dashboard/issues</Support>
|
|
14
|
+
<Project>https://github.com/0xdespot/lido-csm-dashboard</Project>
|
|
15
|
+
<Overview>Lido CSM Operator Dashboard - Track your Community Staking Module validator earnings, bond status, APY metrics, strikes, and health status. Features both a web UI and CLI interface.</Overview>
|
|
16
|
+
<Category>Crypto: Tools:</Category>
|
|
17
|
+
<WebUI>http://[IP]:[PORT:3000]/</WebUI>
|
|
18
|
+
<TemplateURL>https://raw.githubusercontent.com/0xdespot/lido-csm-dashboard/main/unraid-template.xml</TemplateURL>
|
|
19
|
+
<Icon>https://raw.githubusercontent.com/0xdespot/lido-csm-dashboard/main/img/logo.png</Icon>
|
|
20
|
+
<ExtraParams/>
|
|
21
|
+
<PostArgs/>
|
|
22
|
+
<DonateText/>
|
|
23
|
+
<DonateLink/>
|
|
24
|
+
<Requires/>
|
|
25
|
+
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="Web interface port" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
|
26
|
+
<Config Name="App Data" Target="/app/data" Default="/mnt/user/appdata/lido-csm-dashboard" Mode="rw" Description="Application data and cache directory" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/appdata/lido-csm-dashboard</Config>
|
|
27
|
+
<Config Name="ETH RPC URL" Target="ETH_RPC_URL" Default="https://eth.llamarpc.com" Mode="" Description="Ethereum RPC endpoint (default: llamarpc.com)" Type="Variable" Display="always" Required="false" Mask="false">https://eth.llamarpc.com</Config>
|
|
28
|
+
<Config Name="Beacon API URL" Target="BEACON_API_URL" Default="https://beaconcha.in/api/v1" Mode="" Description="Beacon chain API endpoint (default: beaconcha.in)" Type="Variable" Display="always" Required="false" Mask="false">https://beaconcha.in/api/v1</Config>
|
|
29
|
+
<Config Name="Beacon API Key" Target="BEACON_API_KEY" Default="" Mode="" Description="Optional - beaconcha.in API key for higher rate limits" Type="Variable" Display="always" Required="false" Mask="true"/>
|
|
30
|
+
<Config Name="Etherscan API Key" Target="ETHERSCAN_API_KEY" Default="" Mode="" Description="Optional - enables withdrawal history tracking" Type="Variable" Display="always" Required="false" Mask="true"/>
|
|
31
|
+
<Config Name="TheGraph API Key" Target="THEGRAPH_API_KEY" Default="" Mode="" Description="Optional - enables historical stETH APR data" Type="Variable" Display="always" Required="false" Mask="true"/>
|
|
32
|
+
</Container>
|
|
@@ -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
|
+
]
|
|
@@ -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(
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Etherscan API client for event queries."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from web3 import Web3
|
|
5
|
+
|
|
6
|
+
from ..core.config import get_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EtherscanProvider:
|
|
10
|
+
"""Query contract events via Etherscan API."""
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.etherscan.io/v2/api"
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.settings = get_settings()
|
|
16
|
+
self.api_key = self.settings.etherscan_api_key
|
|
17
|
+
|
|
18
|
+
def is_available(self) -> bool:
|
|
19
|
+
"""Check if Etherscan API key is configured."""
|
|
20
|
+
return bool(self.api_key)
|
|
21
|
+
|
|
22
|
+
async def get_distribution_log_events(
|
|
23
|
+
self,
|
|
24
|
+
contract_address: str,
|
|
25
|
+
from_block: int,
|
|
26
|
+
to_block: str | int = "latest",
|
|
27
|
+
) -> list[dict]:
|
|
28
|
+
"""Query DistributionLogUpdated events from Etherscan."""
|
|
29
|
+
if not self.api_key:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
# Event topic: keccak256("DistributionLogUpdated(string)")
|
|
33
|
+
topic0 = "0x" + Web3.keccak(text="DistributionLogUpdated(string)").hex()
|
|
34
|
+
|
|
35
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
36
|
+
response = await client.get(
|
|
37
|
+
self.BASE_URL,
|
|
38
|
+
params={
|
|
39
|
+
"chainid": 1,
|
|
40
|
+
"module": "logs",
|
|
41
|
+
"action": "getLogs",
|
|
42
|
+
"address": contract_address,
|
|
43
|
+
"topic0": topic0,
|
|
44
|
+
"fromBlock": from_block,
|
|
45
|
+
"toBlock": to_block,
|
|
46
|
+
"apikey": self.api_key,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
data = response.json()
|
|
51
|
+
if data.get("status") != "1":
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
results = []
|
|
55
|
+
for log in data.get("result", []):
|
|
56
|
+
# Decode the logCid from the data field
|
|
57
|
+
# The data is ABI-encoded string: offset (32 bytes) + length (32 bytes) + data
|
|
58
|
+
raw_data = log["data"]
|
|
59
|
+
# Skip the offset (0x40 = 64 chars after 0x) and length prefix
|
|
60
|
+
# String data starts at byte 64 (128 hex chars after 0x)
|
|
61
|
+
if len(raw_data) > 130: # 0x + 128 chars minimum
|
|
62
|
+
# Extract length from bytes 32-64
|
|
63
|
+
length_hex = raw_data[66:130]
|
|
64
|
+
length = int(length_hex, 16)
|
|
65
|
+
# Extract string data starting at byte 64
|
|
66
|
+
string_data = raw_data[130 : 130 + length * 2]
|
|
67
|
+
try:
|
|
68
|
+
log_cid = bytes.fromhex(string_data).decode("utf-8")
|
|
69
|
+
results.append(
|
|
70
|
+
{
|
|
71
|
+
"block": int(log["blockNumber"], 16),
|
|
72
|
+
"logCid": log_cid,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
except (ValueError, UnicodeDecodeError):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
return sorted(results, key=lambda x: x["block"])
|
|
79
|
+
|
|
80
|
+
async def get_transfer_events(
|
|
81
|
+
self,
|
|
82
|
+
token_address: str,
|
|
83
|
+
from_address: str,
|
|
84
|
+
to_address: str,
|
|
85
|
+
from_block: int,
|
|
86
|
+
to_block: str | int = "latest",
|
|
87
|
+
) -> list[dict]:
|
|
88
|
+
"""Query Transfer events from Etherscan for a specific from/to pair."""
|
|
89
|
+
if not self.api_key:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
# Event topic: keccak256("Transfer(address,address,uint256)")
|
|
93
|
+
topic0 = "0x" + Web3.keccak(text="Transfer(address,address,uint256)").hex()
|
|
94
|
+
# topic1 is indexed 'from' address (padded to 32 bytes)
|
|
95
|
+
topic1 = "0x" + from_address.lower().replace("0x", "").zfill(64)
|
|
96
|
+
# topic2 is indexed 'to' address (padded to 32 bytes)
|
|
97
|
+
topic2 = "0x" + to_address.lower().replace("0x", "").zfill(64)
|
|
98
|
+
|
|
99
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
100
|
+
response = await client.get(
|
|
101
|
+
self.BASE_URL,
|
|
102
|
+
params={
|
|
103
|
+
"chainid": 1,
|
|
104
|
+
"module": "logs",
|
|
105
|
+
"action": "getLogs",
|
|
106
|
+
"address": token_address,
|
|
107
|
+
"topic0": topic0,
|
|
108
|
+
"topic1": topic1,
|
|
109
|
+
"topic2": topic2,
|
|
110
|
+
"topic0_1_opr": "and",
|
|
111
|
+
"topic1_2_opr": "and",
|
|
112
|
+
"fromBlock": from_block,
|
|
113
|
+
"toBlock": to_block,
|
|
114
|
+
"apikey": self.api_key,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
data = response.json()
|
|
119
|
+
if data.get("status") != "1":
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
results = []
|
|
123
|
+
for log in data.get("result", []):
|
|
124
|
+
# The data field contains the non-indexed value (amount)
|
|
125
|
+
raw_data = log["data"]
|
|
126
|
+
try:
|
|
127
|
+
value = int(raw_data, 16)
|
|
128
|
+
results.append(
|
|
129
|
+
{
|
|
130
|
+
"block": int(log["blockNumber"], 16),
|
|
131
|
+
"tx_hash": log["transactionHash"],
|
|
132
|
+
"value": value,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
except (ValueError, TypeError):
|
|
136
|
+
continue
|
|
137
|
+
|
|
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"])
|