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.
Files changed (54) hide show
  1. csm_dashboard-0.3.6/CHANGELOG.md +30 -0
  2. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/PKG-INFO +1 -1
  3. csm_dashboard-0.3.6/img/favicon.ico +0 -0
  4. csm_dashboard-0.3.6/img/logo.png +0 -0
  5. csm_dashboard-0.3.6/my-lido-csm-dashboard.xml +32 -0
  6. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/pyproject.toml +1 -1
  7. csm_dashboard-0.3.6/src/abis/WithdrawalQueueERC721.json +47 -0
  8. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/cli/commands.py +61 -2
  9. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/config.py +1 -0
  10. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/contracts.py +1 -0
  11. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/types.py +10 -0
  12. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/beacon.py +60 -22
  13. csm_dashboard-0.3.6/src/data/etherscan.py +297 -0
  14. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/onchain.py +228 -8
  15. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/services/operator_service.py +7 -0
  16. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/web/app.py +282 -1
  17. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/web/routes.py +19 -0
  18. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_config.py +1 -0
  19. csm_dashboard-0.3.2/img/logo.png +0 -0
  20. csm_dashboard-0.3.2/src/data/etherscan.py +0 -138
  21. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.dockerignore +0 -0
  22. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.env.example +0 -0
  23. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.github/workflows/docker-publish.yaml +0 -0
  24. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.github/workflows/release.yaml +0 -0
  25. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/.gitignore +0 -0
  26. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/Dockerfile +0 -0
  27. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/README.md +0 -0
  28. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/docker-compose.yml +0 -0
  29. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/img/csm-dash-cli.png +0 -0
  30. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/img/csm-dash-web.png +0 -0
  31. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/requirements.txt +0 -0
  32. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/__init__.py +0 -0
  33. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/CSAccounting.json +0 -0
  34. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/CSFeeDistributor.json +0 -0
  35. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/CSModule.json +0 -0
  36. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/__init__.py +0 -0
  37. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/abis/stETH.json +0 -0
  38. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/cli/__init__.py +0 -0
  39. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/core/__init__.py +0 -0
  40. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/__init__.py +0 -0
  41. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/cache.py +0 -0
  42. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/ipfs_logs.py +0 -0
  43. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/known_cids.py +0 -0
  44. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/lido_api.py +0 -0
  45. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/rewards_tree.py +0 -0
  46. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/data/strikes.py +0 -0
  47. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/main.py +0 -0
  48. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/services/__init__.py +0 -0
  49. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/src/web/__init__.py +0 -0
  50. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/__init__.py +0 -0
  51. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/conftest.py +0 -0
  52. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_cache.py +0 -0
  53. {csm_dashboard-0.3.2 → csm_dashboard-0.3.6}/tests/unit/test_strikes.py +0 -0
  54. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csm-dashboard
3
- Version: 0.3.2
3
+ Version: 0.3.6
4
4
  Summary: Lido CSM Operator Dashboard for tracking validator earnings
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.104
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csm-dashboard"
3
- version = "0.3.2"
3
+ version = "0.3.6"
4
4
  description = "Lido CSM Operator Dashboard for tracking validator earnings"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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("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(
@@ -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
@@ -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")
@@ -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
- try:
188
- response = await client.get(
189
- f"{self.base_url}/validator/{pubkeys_param}",
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
- if response.status_code == 200:
194
- data = response.json().get("data", [])
195
- # API returns single object if only one validator
196
- if isinstance(data, dict):
197
- data = [data]
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
- for v in data:
200
- validators.append(self._parse_validator(v))
201
- elif response.status_code == 404:
202
- # Validators not found - create placeholder entries
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
- except Exception:
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"])