csm-dashboard 0.3.2__tar.gz → 0.3.5__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.5/CHANGELOG.md +25 -0
  2. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/PKG-INFO +1 -1
  3. csm_dashboard-0.3.5/img/favicon.ico +0 -0
  4. csm_dashboard-0.3.5/img/logo.png +0 -0
  5. csm_dashboard-0.3.5/my-lido-csm-dashboard.xml +32 -0
  6. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/pyproject.toml +1 -1
  7. csm_dashboard-0.3.5/src/abis/WithdrawalQueueERC721.json +47 -0
  8. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/cli/commands.py +61 -2
  9. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/core/config.py +1 -0
  10. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/core/contracts.py +1 -0
  11. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/core/types.py +10 -0
  12. csm_dashboard-0.3.5/src/data/etherscan.py +297 -0
  13. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/onchain.py +228 -8
  14. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/services/operator_service.py +7 -0
  15. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/web/app.py +282 -1
  16. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/web/routes.py +19 -0
  17. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/tests/unit/test_config.py +1 -0
  18. csm_dashboard-0.3.2/img/logo.png +0 -0
  19. csm_dashboard-0.3.2/src/data/etherscan.py +0 -138
  20. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/.dockerignore +0 -0
  21. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/.env.example +0 -0
  22. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/.github/workflows/docker-publish.yaml +0 -0
  23. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/.github/workflows/release.yaml +0 -0
  24. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/.gitignore +0 -0
  25. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/Dockerfile +0 -0
  26. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/README.md +0 -0
  27. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/docker-compose.yml +0 -0
  28. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/img/csm-dash-cli.png +0 -0
  29. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/img/csm-dash-web.png +0 -0
  30. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/requirements.txt +0 -0
  31. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/__init__.py +0 -0
  32. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/abis/CSAccounting.json +0 -0
  33. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/abis/CSFeeDistributor.json +0 -0
  34. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/abis/CSModule.json +0 -0
  35. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/abis/__init__.py +0 -0
  36. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/abis/stETH.json +0 -0
  37. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/cli/__init__.py +0 -0
  38. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/core/__init__.py +0 -0
  39. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/__init__.py +0 -0
  40. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/beacon.py +0 -0
  41. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/cache.py +0 -0
  42. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/ipfs_logs.py +0 -0
  43. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/known_cids.py +0 -0
  44. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/lido_api.py +0 -0
  45. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/rewards_tree.py +0 -0
  46. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/data/strikes.py +0 -0
  47. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/main.py +0 -0
  48. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/services/__init__.py +0 -0
  49. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/src/web/__init__.py +0 -0
  50. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/tests/__init__.py +0 -0
  51. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/tests/conftest.py +0 -0
  52. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/tests/unit/test_cache.py +0 -0
  53. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/tests/unit/test_strikes.py +0 -0
  54. {csm_dashboard-0.3.2 → csm_dashboard-0.3.5}/tests/unit/test_types.py +0 -0
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## [0.3.5] - 2026-01-05
4
+
5
+ ### Added
6
+ - **Web:** Withdrawal History section with Load/Hide button toggle
7
+ - **Web:** Distribution History section with Load/Hide button toggle
8
+ - **Web:** Next Distribution info (estimated date and rewards)
9
+ - **Web:** Favicon support
10
+
11
+ ### Fixed
12
+ - **CLI/Web:** unstETH withdrawals now correctly show as "unstETH" type with ETH amounts
13
+ - **CLI:** Added total row to Withdrawal History table
14
+
15
+ ### Changed
16
+ - **Web:** History toggles changed from checkboxes to buttons for better UX
17
+
18
+ ## [0.3.4] - 2025-12
19
+
20
+ ### Added
21
+ - unstETH (Lido Withdrawal NFT) tracking for `claimRewardsUnstETH` claims
22
+ - Withdrawal status tracking (Pending/Ready/Claimed)
23
+
24
+ ## [0.3.3] and earlier
25
+ - 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.5
4
4
  Summary: Lido CSM Operator Dashboard for tracking validator earnings
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.104
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.5"
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.
@@ -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"])