brawny 0.1.13__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.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,132 @@
1
+ """Alerts extension with contract handles, ABI resolution, and event decoding.
2
+
3
+ This extension provides an ergonomic interface for job authors to:
4
+ - Interact with contracts in alert hooks
5
+ - Decode events from transaction receipts (brownie-compatible)
6
+ - Make contract reads
7
+ - Format messages with explorer links
8
+
9
+ Key components:
10
+ - AlertContext: Context passed to alert hooks with event access
11
+ - ContractHandle: Interface for contract function calls
12
+ - EventDict: Brownie-compatible event container
13
+ - ABIResolver: Automatic ABI resolution with caching
14
+
15
+ Formatting helpers (Markdown is the default):
16
+ - shorten(hash): "0x1234...5678"
17
+ - explorer_link(hash): "[🔗 View on Explorer](url)"
18
+ - escape_markdown_v2(text): Escapes special characters
19
+
20
+ Usage in alert hooks:
21
+
22
+ from brawny import Contract
23
+ from brawny.alerts import shorten, explorer_link
24
+
25
+ def alert_confirmed(self, ctx: AlertContext) -> str:
26
+ # Get contract handle (brownie-style)
27
+ token = Contract("token")
28
+
29
+ # Decode events from receipt (brownie-compatible)
30
+ deposit = ctx.events["Deposit"][0]
31
+ amount = deposit["assets"]
32
+
33
+ # Make contract reads
34
+ symbol = token.symbol()
35
+ decimals = token.decimals()
36
+
37
+ # Format with explorer links
38
+ tx_link = explorer_link(ctx.receipt.transactionHash.hex())
39
+
40
+ return f"Deposited {amount / 10**decimals} {symbol}\\n{tx_link}"
41
+ """
42
+
43
+ from brawny.alerts.context import AlertContext, JobMetadata
44
+ from brawny.alerts.contracts import (
45
+ ContractSystem,
46
+ ContractHandle,
47
+ FunctionCaller,
48
+ ExplicitFunctionCaller,
49
+ )
50
+ from brawny.alerts.events import (
51
+ EventAccessor,
52
+ DecodedEvent,
53
+ AttributeDict,
54
+ LogEntry,
55
+ )
56
+ from brawny.alerts.abi_resolver import ABIResolver, ResolvedABI
57
+ from brawny.alerts.base import (
58
+ shorten,
59
+ explorer_link,
60
+ escape_markdown_v2,
61
+ get_explorer_url,
62
+ format_tx_link,
63
+ format_address_link,
64
+ )
65
+ from brawny.alerts.send import (
66
+ AlertEvent,
67
+ AlertPayload,
68
+ AlertConfig,
69
+ send_alert,
70
+ alert,
71
+ )
72
+ from brawny.alerts.errors import (
73
+ DXError,
74
+ ABINotFoundError,
75
+ ProxyResolutionError,
76
+ StateChangingCallError,
77
+ ReceiptRequiredError,
78
+ EventNotFoundError,
79
+ AmbiguousOverloadError,
80
+ OverloadMatchError,
81
+ FunctionNotFoundError,
82
+ InvalidAddressError,
83
+ EventDecodeError,
84
+ ContractCallError,
85
+ ABICacheError,
86
+ )
87
+
88
+ __all__ = [
89
+ # Context
90
+ "AlertContext",
91
+ "JobMetadata",
92
+ # Contracts
93
+ "ContractHandle",
94
+ "FunctionCaller",
95
+ "ExplicitFunctionCaller",
96
+ "ContractSystem",
97
+ # Events
98
+ "EventAccessor",
99
+ "DecodedEvent",
100
+ "AttributeDict",
101
+ "LogEntry",
102
+ # ABI Resolution
103
+ "ABIResolver",
104
+ "ResolvedABI",
105
+ # Alert System
106
+ "AlertEvent",
107
+ "AlertPayload",
108
+ "AlertConfig",
109
+ "send_alert",
110
+ "alert",
111
+ # Formatting
112
+ "shorten",
113
+ "explorer_link",
114
+ "escape_markdown_v2",
115
+ "get_explorer_url",
116
+ "format_tx_link",
117
+ "format_address_link",
118
+ # Errors
119
+ "DXError",
120
+ "ABINotFoundError",
121
+ "ProxyResolutionError",
122
+ "StateChangingCallError",
123
+ "ReceiptRequiredError",
124
+ "EventNotFoundError",
125
+ "AmbiguousOverloadError",
126
+ "OverloadMatchError",
127
+ "FunctionNotFoundError",
128
+ "InvalidAddressError",
129
+ "EventDecodeError",
130
+ "ContractCallError",
131
+ "ABICacheError",
132
+ ]
@@ -0,0 +1,530 @@
1
+ """ABI resolution with Etherscan, Sourcify fallback, and proxy detection.
2
+
3
+ This module handles:
4
+ 1. ABI fetching from Etherscan API
5
+ 2. Sourcify fallback when Etherscan fails
6
+ 3. EIP-1967 proxy detection and implementation resolution
7
+ 4. Persistent caching in global ~/.brawny/abi_cache.db
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import requests
19
+ from eth_utils import to_checksum_address
20
+
21
+ from brawny.alerts.errors import (
22
+ ABINotFoundError,
23
+ InvalidAddressError,
24
+ ProxyResolutionError,
25
+ )
26
+ from brawny.db.global_cache import GlobalABICache
27
+ from brawny.logging import get_logger
28
+
29
+ if TYPE_CHECKING:
30
+ from brawny.config import Config
31
+ from brawny._rpc.manager import RPCManager
32
+
33
+
34
+ logger = get_logger(__name__)
35
+
36
+ # EIP-1967 storage slots for proxy detection
37
+ IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
38
+ BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
39
+ ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"
40
+
41
+ # Empty slot value (32 zero bytes)
42
+ EMPTY_SLOT = "0x" + "00" * 32
43
+
44
+ # Etherscan v2 API unified endpoint
45
+ ETHERSCAN_V2_API_URL = "https://api.etherscan.io/v2/api"
46
+
47
+ # Supported chain IDs for Etherscan v2 API
48
+ ETHERSCAN_SUPPORTED_CHAINS = {
49
+ 1, # Ethereum Mainnet
50
+ 5, # Goerli (deprecated)
51
+ 11155111, # Sepolia
52
+ 137, # Polygon
53
+ 42161, # Arbitrum One
54
+ 10, # Optimism
55
+ 8453, # Base
56
+ 56, # BSC
57
+ 43114, # Avalanche C-Chain
58
+ 250, # Fantom
59
+ 42170, # Arbitrum Nova
60
+ 59144, # Linea
61
+ 534352, # Scroll
62
+ 324, # zkSync Era
63
+ }
64
+
65
+ # Chain ID to Sourcify chain name
66
+ SOURCIFY_CHAIN_IDS = {
67
+ 1: "1",
68
+ 5: "5",
69
+ 11155111: "11155111",
70
+ 137: "137",
71
+ 42161: "42161",
72
+ 10: "10",
73
+ 8453: "8453",
74
+ 56: "56",
75
+ 43114: "43114",
76
+ 250: "250",
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class ResolvedABI:
82
+ """Result of ABI resolution."""
83
+
84
+ address: str
85
+ abi: list[dict[str, Any]]
86
+ source: str # 'etherscan', 'sourcify', 'manual', 'proxy_implementation'
87
+ implementation_address: str | None = None # Set if resolved through proxy
88
+
89
+
90
+ class ABIResolver:
91
+ """Resolves contract ABIs with caching and proxy support.
92
+
93
+ Resolution order:
94
+ 1. Check database cache (with TTL validation)
95
+ 2. Detect if address is a proxy and resolve implementation
96
+ 3. Try Etherscan API
97
+ 4. Try Sourcify as fallback
98
+ 5. Raise ABINotFoundError if all fail
99
+
100
+ Proxy detection:
101
+ - Checks EIP-1967 implementation slot
102
+ - Checks EIP-1967 beacon slot (and reads implementation from beacon)
103
+ - Recursively resolves up to 3 levels deep
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ rpc: RPCManager,
109
+ config: Config,
110
+ abi_cache: GlobalABICache | None = None,
111
+ ) -> None:
112
+ self.abi_cache = abi_cache or GlobalABICache()
113
+ self.rpc = rpc
114
+ self.config = config
115
+ self._etherscan_api_url = ETHERSCAN_V2_API_URL
116
+ self._etherscan_api_key = os.environ.get("ETHERSCAN_API_KEY")
117
+ self._sourcify_enabled = True
118
+ self._request_timeout = 10.0
119
+
120
+ def resolve(
121
+ self,
122
+ address: str,
123
+ chain_id: int | None = None,
124
+ force_refresh: bool = False,
125
+ ) -> ResolvedABI:
126
+ """Resolve ABI for a contract address.
127
+
128
+ Args:
129
+ address: Contract address to resolve ABI for
130
+ chain_id: Chain ID (defaults to config.chain_id)
131
+ force_refresh: Bypass cache and fetch fresh ABI
132
+
133
+ Returns:
134
+ ResolvedABI with the contract ABI and metadata
135
+
136
+ Raises:
137
+ ABINotFoundError: If ABI cannot be resolved from any source
138
+ InvalidAddressError: If address is invalid
139
+ """
140
+ # Validate and normalize address
141
+ try:
142
+ address = to_checksum_address(address)
143
+ except Exception:
144
+ raise InvalidAddressError(address)
145
+
146
+ chain_id = chain_id or self.config.chain_id
147
+ checked_sources: list[str] = []
148
+
149
+ # Step 1: Check cache (unless force refresh)
150
+ if not force_refresh:
151
+ cached = self.abi_cache.get_cached_abi(chain_id, address)
152
+ if cached and not self._is_cache_expired(cached.resolved_at):
153
+ return ResolvedABI(
154
+ address=address,
155
+ abi=json.loads(cached.abi_json),
156
+ source=cached.source,
157
+ )
158
+
159
+ # Step 2: Check if proxy and resolve implementation
160
+ impl_address = self._resolve_proxy_implementation(chain_id, address)
161
+ if impl_address and impl_address != address:
162
+ logger.debug(
163
+ "abi.proxy_detected",
164
+ proxy=address,
165
+ implementation=impl_address,
166
+ )
167
+ # Resolve ABI for implementation
168
+ try:
169
+ impl_abi = self._fetch_abi(chain_id, impl_address, checked_sources)
170
+ if impl_abi:
171
+ # Cache both the proxy resolution and the ABI
172
+ self.abi_cache.set_cached_proxy(chain_id, address, impl_address)
173
+ self.abi_cache.set_cached_abi(
174
+ chain_id,
175
+ address,
176
+ json.dumps(impl_abi),
177
+ "proxy_implementation",
178
+ )
179
+ return ResolvedABI(
180
+ address=address,
181
+ abi=impl_abi,
182
+ source="proxy_implementation",
183
+ implementation_address=impl_address,
184
+ )
185
+ except Exception as e:
186
+ logger.warning(
187
+ "abi.impl_resolution_failed",
188
+ proxy=address,
189
+ implementation=impl_address,
190
+ error=str(e),
191
+ )
192
+
193
+ # Step 3: Try to fetch ABI directly
194
+ abi = self._fetch_abi(chain_id, address, checked_sources)
195
+ if abi:
196
+ source = checked_sources[-1] if checked_sources else "unknown"
197
+ self.abi_cache.set_cached_abi(chain_id, address, json.dumps(abi), source)
198
+ return ResolvedABI(address=address, abi=abi, source=source)
199
+
200
+ raise ABINotFoundError(address, checked_sources)
201
+
202
+ def _is_cache_expired(self, resolved_at: datetime) -> bool:
203
+ """Check if cached entry is expired based on TTL."""
204
+ if self.config.abi_cache_ttl_seconds <= 0:
205
+ return True
206
+
207
+ now = datetime.now(timezone.utc)
208
+ if resolved_at.tzinfo is None:
209
+ resolved_at = resolved_at.replace(tzinfo=timezone.utc)
210
+
211
+ age_seconds = (now - resolved_at).total_seconds()
212
+ return age_seconds > self.config.abi_cache_ttl_seconds
213
+
214
+ def _resolve_proxy_implementation(
215
+ self,
216
+ chain_id: int,
217
+ address: str,
218
+ depth: int = 0,
219
+ ) -> str | None:
220
+ """Resolve proxy to implementation address using EIP-1967.
221
+
222
+ Args:
223
+ chain_id: Chain ID
224
+ address: Proxy address
225
+ depth: Current recursion depth (max 3)
226
+
227
+ Returns:
228
+ Implementation address or None if not a proxy
229
+ """
230
+ if depth > 3:
231
+ logger.warning("proxy.max_depth_exceeded", address=address)
232
+ return None
233
+
234
+ # Check cache first
235
+ cached = self.abi_cache.get_cached_proxy(chain_id, address)
236
+ if cached:
237
+ # Recursively check if cached implementation is also a proxy
238
+ nested = self._resolve_proxy_implementation(
239
+ chain_id, cached.implementation_address, depth + 1
240
+ )
241
+ return nested or cached.implementation_address
242
+
243
+ # Try EIP-1967 implementation slot
244
+ try:
245
+ impl = self._read_storage_slot(address, IMPLEMENTATION_SLOT)
246
+ if impl and impl != EMPTY_SLOT:
247
+ impl_address = self._slot_to_address(impl)
248
+ if impl_address:
249
+ # Recursively check if implementation is also a proxy
250
+ nested = self._resolve_proxy_implementation(
251
+ chain_id, impl_address, depth + 1
252
+ )
253
+ final_impl = nested or impl_address
254
+ self.abi_cache.set_cached_proxy(chain_id, address, final_impl)
255
+ return final_impl
256
+ except Exception as e:
257
+ logger.debug("proxy.impl_slot_error", address=address, error=str(e))
258
+
259
+ # Try EIP-1967 beacon slot
260
+ try:
261
+ beacon = self._read_storage_slot(address, BEACON_SLOT)
262
+ if beacon and beacon != EMPTY_SLOT:
263
+ beacon_address = self._slot_to_address(beacon)
264
+ if beacon_address:
265
+ # Read implementation from beacon
266
+ impl_address = self._read_beacon_implementation(beacon_address)
267
+ if impl_address:
268
+ nested = self._resolve_proxy_implementation(
269
+ chain_id, impl_address, depth + 1
270
+ )
271
+ final_impl = nested or impl_address
272
+ self.abi_cache.set_cached_proxy(chain_id, address, final_impl)
273
+ return final_impl
274
+ except Exception as e:
275
+ logger.debug("proxy.beacon_slot_error", address=address, error=str(e))
276
+
277
+ return None
278
+
279
+ def _read_storage_slot(self, address: str, slot: str) -> str | None:
280
+ """Read a storage slot from a contract."""
281
+ try:
282
+ result = self.rpc.get_storage_at(address, slot)
283
+ if result is None:
284
+ return None
285
+ # Convert bytes to hex string if needed
286
+ if isinstance(result, bytes):
287
+ return "0x" + result.hex()
288
+ return result
289
+ except Exception:
290
+ return None
291
+
292
+ def _slot_to_address(self, slot_value: str) -> str | None:
293
+ """Extract address from storage slot value."""
294
+ if not slot_value or slot_value == EMPTY_SLOT:
295
+ return None
296
+
297
+ # Remove 0x prefix and take last 40 chars (20 bytes = address)
298
+ if slot_value.startswith("0x"):
299
+ slot_value = slot_value[2:]
300
+
301
+ if len(slot_value) < 40:
302
+ return None
303
+
304
+ # Address is in the last 40 characters
305
+ addr_hex = slot_value[-40:]
306
+
307
+ # Check if it's a valid non-zero address
308
+ if addr_hex == "0" * 40:
309
+ return None
310
+
311
+ try:
312
+ return to_checksum_address("0x" + addr_hex)
313
+ except Exception:
314
+ return None
315
+
316
+ def _read_beacon_implementation(self, beacon_address: str) -> str | None:
317
+ """Read implementation address from a beacon contract.
318
+
319
+ Beacons typically have an implementation() function.
320
+ """
321
+ # implementation() function selector: 0x5c60da1b
322
+ try:
323
+ tx_params = {"to": beacon_address, "data": "0x5c60da1b"}
324
+ result = self.rpc.eth_call(tx_params)
325
+ if result:
326
+ # Convert bytes to hex string if needed
327
+ if isinstance(result, bytes):
328
+ result = "0x" + result.hex()
329
+ if result != "0x":
330
+ return self._slot_to_address(result)
331
+ except Exception as e:
332
+ logger.debug(
333
+ "proxy.beacon_call_error",
334
+ beacon=beacon_address,
335
+ error=str(e),
336
+ )
337
+ return None
338
+
339
+ def _fetch_abi(
340
+ self,
341
+ chain_id: int,
342
+ address: str,
343
+ checked_sources: list[str],
344
+ ) -> list[dict[str, Any]] | None:
345
+ """Fetch ABI from external sources.
346
+
347
+ Tries Etherscan v2 API first, then Sourcify as fallback.
348
+ """
349
+ # Try Etherscan first (works without API key, but rate-limited)
350
+ if chain_id in ETHERSCAN_SUPPORTED_CHAINS:
351
+ checked_sources.append("etherscan")
352
+ abi = self._fetch_from_etherscan(chain_id, address)
353
+ if abi:
354
+ return abi
355
+
356
+ # Try Sourcify as fallback if enabled
357
+ if self._sourcify_enabled:
358
+ checked_sources.append("sourcify")
359
+ abi = self._fetch_from_sourcify(chain_id, address)
360
+ if abi:
361
+ return abi
362
+
363
+ return None
364
+
365
+ def _fetch_from_etherscan(
366
+ self,
367
+ chain_id: int,
368
+ address: str,
369
+ ) -> list[dict[str, Any]] | None:
370
+ """Fetch ABI from Etherscan v2 API.
371
+
372
+ Uses the unified v2 endpoint with chainid parameter.
373
+ Works without API key but with stricter rate limits.
374
+ """
375
+ api_url = self._etherscan_api_url
376
+
377
+ params: dict[str, Any] = {
378
+ "chainid": chain_id,
379
+ "module": "contract",
380
+ "action": "getabi",
381
+ "address": address,
382
+ }
383
+
384
+ # Add API key if configured (higher rate limits)
385
+ if self._etherscan_api_key:
386
+ params["apikey"] = self._etherscan_api_key
387
+
388
+ try:
389
+ response = requests.get(
390
+ api_url,
391
+ params=params,
392
+ timeout=self._request_timeout,
393
+ )
394
+ response.raise_for_status()
395
+
396
+ data = response.json()
397
+ if data.get("status") == "1" and data.get("result"):
398
+ abi = json.loads(data["result"])
399
+ logger.debug(
400
+ "etherscan.abi_fetched",
401
+ address=address,
402
+ chain_id=chain_id,
403
+ )
404
+ return abi
405
+ else:
406
+ logger.debug(
407
+ "etherscan.abi_not_found",
408
+ address=address,
409
+ chain_id=chain_id,
410
+ message=data.get("message"),
411
+ )
412
+ except requests.RequestException as e:
413
+ logger.warning(
414
+ "etherscan.request_error",
415
+ address=address,
416
+ chain_id=chain_id,
417
+ error=str(e),
418
+ )
419
+ except json.JSONDecodeError as e:
420
+ logger.warning(
421
+ "etherscan.json_error",
422
+ address=address,
423
+ chain_id=chain_id,
424
+ error=str(e),
425
+ )
426
+
427
+ return None
428
+
429
+ def _fetch_from_sourcify(
430
+ self,
431
+ chain_id: int,
432
+ address: str,
433
+ ) -> list[dict[str, Any]] | None:
434
+ """Fetch ABI from Sourcify."""
435
+ # Try full match first
436
+ abi = self._fetch_sourcify_match(chain_id, address, full_match=True)
437
+ if abi:
438
+ return abi
439
+
440
+ # Try partial match
441
+ return self._fetch_sourcify_match(chain_id, address, full_match=False)
442
+
443
+ def _fetch_sourcify_match(
444
+ self,
445
+ chain_id: int,
446
+ address: str,
447
+ full_match: bool,
448
+ ) -> list[dict[str, Any]] | None:
449
+ """Fetch from Sourcify with specific match type."""
450
+ match_type = "full_match" if full_match else "partial_match"
451
+ url = f"https://sourcify.dev/server/repository/contracts/{match_type}/{chain_id}/{address}/metadata.json"
452
+
453
+ try:
454
+ response = requests.get(url, timeout=self._request_timeout)
455
+ if response.status_code == 200:
456
+ metadata = response.json()
457
+ if "output" in metadata and "abi" in metadata["output"]:
458
+ logger.debug(
459
+ "sourcify.abi_fetched",
460
+ address=address,
461
+ match_type=match_type,
462
+ )
463
+ return metadata["output"]["abi"]
464
+ except requests.RequestException as e:
465
+ logger.debug("sourcify.request_error", address=address, error=str(e))
466
+ except json.JSONDecodeError as e:
467
+ logger.debug("sourcify.json_error", address=address, error=str(e))
468
+
469
+ return None
470
+
471
+ def clear_cache(self, address: str, chain_id: int | None = None) -> bool:
472
+ """Clear cached ABI and proxy resolution for an address.
473
+
474
+ Args:
475
+ address: Contract address
476
+ chain_id: Chain ID (defaults to config.chain_id)
477
+
478
+ Returns:
479
+ True if cache entry was cleared
480
+ """
481
+ chain_id = chain_id or self.config.chain_id
482
+ try:
483
+ address = to_checksum_address(address)
484
+ except Exception:
485
+ raise InvalidAddressError(address)
486
+
487
+ abi_cleared = self.abi_cache.clear_cached_abi(chain_id, address)
488
+ proxy_cleared = self.abi_cache.clear_cached_proxy(chain_id, address)
489
+
490
+ return abi_cleared or proxy_cleared
491
+
492
+ def set_manual_abi(
493
+ self,
494
+ address: str,
495
+ abi: list[dict[str, Any]],
496
+ chain_id: int | None = None,
497
+ ) -> None:
498
+ """Manually set ABI for a contract.
499
+
500
+ Args:
501
+ address: Contract address
502
+ abi: ABI to cache
503
+ chain_id: Chain ID (defaults to config.chain_id)
504
+ """
505
+ chain_id = chain_id or self.config.chain_id
506
+ try:
507
+ address = to_checksum_address(address)
508
+ except Exception:
509
+ raise InvalidAddressError(address)
510
+
511
+ self.abi_cache.set_cached_abi(chain_id, address, json.dumps(abi), "manual")
512
+ logger.info("abi.manual_set", address=address, chain_id=chain_id)
513
+
514
+
515
+ def get_function_signature(name: str, inputs: list[dict[str, Any]]) -> str:
516
+ """Build function signature string from name and inputs.
517
+
518
+ Example: "transfer(address,uint256)"
519
+ """
520
+ types = [inp["type"] for inp in inputs]
521
+ return f"{name}({','.join(types)})"
522
+
523
+
524
+ def get_event_signature(name: str, inputs: list[dict[str, Any]]) -> str:
525
+ """Build event signature string from name and inputs.
526
+
527
+ Example: "Transfer(address,address,uint256)"
528
+ """
529
+ types = [inp["type"] for inp in inputs]
530
+ return f"{name}({','.join(types)})"