brawny 0.1.13__py3-none-any.whl → 0.1.22__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.
- brawny/__init__.py +2 -0
- brawny/_context.py +5 -5
- brawny/_rpc/__init__.py +36 -12
- brawny/_rpc/broadcast.py +14 -13
- brawny/_rpc/caller.py +243 -0
- brawny/_rpc/client.py +539 -0
- brawny/_rpc/clients.py +11 -11
- brawny/_rpc/context.py +23 -0
- brawny/_rpc/errors.py +465 -31
- brawny/_rpc/gas.py +7 -6
- brawny/_rpc/pool.py +18 -0
- brawny/_rpc/retry.py +266 -0
- brawny/_rpc/retry_policy.py +81 -0
- brawny/accounts.py +28 -9
- brawny/alerts/__init__.py +15 -18
- brawny/alerts/abi_resolver.py +212 -36
- brawny/alerts/base.py +2 -2
- brawny/alerts/contracts.py +77 -10
- brawny/alerts/errors.py +30 -3
- brawny/alerts/events.py +38 -5
- brawny/alerts/health.py +19 -13
- brawny/alerts/send.py +513 -55
- brawny/api.py +39 -11
- brawny/assets/AGENTS.md +325 -0
- brawny/async_runtime.py +48 -0
- brawny/chain.py +3 -3
- brawny/cli/commands/__init__.py +2 -0
- brawny/cli/commands/console.py +69 -19
- brawny/cli/commands/contract.py +2 -2
- brawny/cli/commands/controls.py +121 -0
- brawny/cli/commands/health.py +2 -2
- brawny/cli/commands/job_dev.py +6 -5
- brawny/cli/commands/jobs.py +99 -2
- brawny/cli/commands/maintenance.py +13 -29
- brawny/cli/commands/migrate.py +1 -0
- brawny/cli/commands/run.py +10 -3
- brawny/cli/commands/script.py +8 -3
- brawny/cli/commands/signer.py +143 -26
- brawny/cli/helpers.py +0 -3
- brawny/cli_templates.py +25 -349
- brawny/config/__init__.py +4 -1
- brawny/config/models.py +43 -57
- brawny/config/parser.py +268 -57
- brawny/config/validation.py +52 -15
- brawny/daemon/context.py +4 -2
- brawny/daemon/core.py +185 -63
- brawny/daemon/loops.py +166 -98
- brawny/daemon/supervisor.py +261 -0
- brawny/db/__init__.py +14 -26
- brawny/db/base.py +248 -151
- brawny/db/global_cache.py +11 -1
- brawny/db/migrate.py +175 -28
- brawny/db/migrations/001_init.sql +4 -3
- brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
- brawny/db/migrations/011_add_job_logs.sql +1 -2
- brawny/db/migrations/012_add_claimed_by.sql +2 -2
- brawny/db/migrations/013_attempt_unique.sql +10 -0
- brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
- brawny/db/migrations/015_add_signer_alias.sql +14 -0
- brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
- brawny/db/migrations/017_add_job_drain.sql +6 -0
- brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
- brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
- brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
- brawny/db/ops/__init__.py +3 -25
- brawny/db/ops/logs.py +1 -2
- brawny/db/queries.py +47 -91
- brawny/db/serialized.py +65 -0
- brawny/db/sqlite/__init__.py +1001 -0
- brawny/db/sqlite/connection.py +231 -0
- brawny/db/sqlite/execute.py +116 -0
- brawny/db/sqlite/mappers.py +190 -0
- brawny/db/sqlite/repos/attempts.py +372 -0
- brawny/db/sqlite/repos/block_state.py +102 -0
- brawny/db/sqlite/repos/cache.py +104 -0
- brawny/db/sqlite/repos/intents.py +1021 -0
- brawny/db/sqlite/repos/jobs.py +200 -0
- brawny/db/sqlite/repos/maintenance.py +182 -0
- brawny/db/sqlite/repos/signers_nonces.py +566 -0
- brawny/db/sqlite/tx.py +119 -0
- brawny/http.py +194 -0
- brawny/invariants.py +11 -24
- brawny/jobs/base.py +8 -0
- brawny/jobs/job_validation.py +2 -1
- brawny/keystore.py +83 -7
- brawny/lifecycle.py +64 -12
- brawny/logging.py +0 -2
- brawny/metrics.py +84 -12
- brawny/model/contexts.py +111 -9
- brawny/model/enums.py +1 -0
- brawny/model/errors.py +18 -0
- brawny/model/types.py +47 -131
- brawny/network_guard.py +133 -0
- brawny/networks/__init__.py +5 -5
- brawny/networks/config.py +1 -7
- brawny/networks/manager.py +14 -11
- brawny/runtime_controls.py +74 -0
- brawny/scheduler/poller.py +11 -7
- brawny/scheduler/reorg.py +95 -39
- brawny/scheduler/runner.py +442 -168
- brawny/scheduler/shutdown.py +3 -3
- brawny/script_tx.py +3 -3
- brawny/telegram.py +53 -7
- brawny/testing.py +1 -0
- brawny/timeout.py +38 -0
- brawny/tx/executor.py +922 -308
- brawny/tx/intent.py +54 -16
- brawny/tx/monitor.py +31 -12
- brawny/tx/nonce.py +212 -90
- brawny/tx/replacement.py +69 -18
- brawny/tx/retry_policy.py +24 -0
- brawny/tx/stages/types.py +75 -0
- brawny/types.py +18 -0
- brawny/utils.py +41 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
- brawny-0.1.22.dist-info/RECORD +163 -0
- brawny/_rpc/manager.py +0 -982
- brawny/_rpc/selector.py +0 -156
- brawny/db/base_new.py +0 -165
- brawny/db/mappers.py +0 -182
- brawny/db/migrations/008_add_transactions.sql +0 -72
- brawny/db/ops/attempts.py +0 -108
- brawny/db/ops/blocks.py +0 -83
- brawny/db/ops/cache.py +0 -93
- brawny/db/ops/intents.py +0 -296
- brawny/db/ops/jobs.py +0 -110
- brawny/db/ops/nonces.py +0 -322
- brawny/db/postgres.py +0 -2535
- brawny/db/postgres_new.py +0 -196
- brawny/db/sqlite.py +0 -2733
- brawny/db/sqlite_new.py +0 -191
- brawny-0.1.13.dist-info/RECORD +0 -141
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
brawny/alerts/abi_resolver.py
CHANGED
|
@@ -11,11 +11,14 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
14
16
|
from dataclasses import dataclass
|
|
15
17
|
from datetime import datetime, timezone
|
|
16
18
|
from typing import TYPE_CHECKING, Any
|
|
17
19
|
|
|
18
|
-
import
|
|
20
|
+
import httpx
|
|
21
|
+
from cachetools import TTLCache
|
|
19
22
|
from eth_utils import to_checksum_address
|
|
20
23
|
|
|
21
24
|
from brawny.alerts.errors import (
|
|
@@ -25,14 +28,80 @@ from brawny.alerts.errors import (
|
|
|
25
28
|
)
|
|
26
29
|
from brawny.db.global_cache import GlobalABICache
|
|
27
30
|
from brawny.logging import get_logger
|
|
31
|
+
from brawny.network_guard import allow_network_calls
|
|
28
32
|
|
|
29
33
|
if TYPE_CHECKING:
|
|
30
34
|
from brawny.config import Config
|
|
31
|
-
from brawny._rpc.
|
|
35
|
+
from brawny._rpc.clients import ReadClient
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
logger = get_logger(__name__)
|
|
35
39
|
|
|
40
|
+
# Short timeout to avoid blocking workers (Plan 08)
|
|
41
|
+
_FETCH_TIMEOUT = 2.0
|
|
42
|
+
|
|
43
|
+
# Address validation: 40 hex chars, optional 0x prefix
|
|
44
|
+
_ADDR_RE = re.compile(r"^(0x)?[0-9a-fA-F]{40}$")
|
|
45
|
+
|
|
46
|
+
# In-memory caches with different TTLs (fast-path before database):
|
|
47
|
+
# - Success cache: ABIs are immutable, cache for 24h
|
|
48
|
+
# - Failure cache: Cache misses for 30min (allows recovery if API fixed)
|
|
49
|
+
# Multi-threaded access (from worker threads) - protected by _mem_cache_lock
|
|
50
|
+
_mem_abi_cache: TTLCache[str, list[dict[str, Any]]] = TTLCache(maxsize=5_000, ttl=24 * 3600)
|
|
51
|
+
_mem_abi_not_found: TTLCache[str, bool] = TTLCache(maxsize=1_000, ttl=30 * 60)
|
|
52
|
+
_mem_cache_lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
# Module-level HTTP client for connection pooling
|
|
55
|
+
_http_client: httpx.Client | None = httpx.Client(timeout=_FETCH_TIMEOUT)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def close_http_client() -> None:
|
|
59
|
+
"""Close HTTP client on shutdown. Idempotent + safe in partial init."""
|
|
60
|
+
global _http_client
|
|
61
|
+
client, _http_client = _http_client, None
|
|
62
|
+
if client is not None:
|
|
63
|
+
client.close()
|
|
64
|
+
# Also clear in-memory caches
|
|
65
|
+
with _mem_cache_lock:
|
|
66
|
+
_mem_abi_cache.clear()
|
|
67
|
+
_mem_abi_not_found.clear()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def clear_memory_cache() -> None:
|
|
71
|
+
"""Clear in-memory ABI caches. Useful for testing."""
|
|
72
|
+
with _mem_cache_lock:
|
|
73
|
+
_mem_abi_cache.clear()
|
|
74
|
+
_mem_abi_not_found.clear()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _client() -> httpx.Client:
|
|
78
|
+
"""Get HTTP client, fail fast if closed."""
|
|
79
|
+
if _http_client is None:
|
|
80
|
+
raise RuntimeError("HTTP client is closed")
|
|
81
|
+
return _http_client
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_valid_address(address: str) -> bool:
|
|
85
|
+
"""Check if string looks like a valid Ethereum address."""
|
|
86
|
+
return bool(_ADDR_RE.match(address))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_address(address: str) -> str:
|
|
90
|
+
"""Normalize address for cache key: lowercase with 0x prefix.
|
|
91
|
+
|
|
92
|
+
Caller should validate with _is_valid_address() first.
|
|
93
|
+
"""
|
|
94
|
+
addr = address.lower()
|
|
95
|
+
if not addr.startswith("0x"):
|
|
96
|
+
addr = "0x" + addr
|
|
97
|
+
return addr
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mem_cache_key(chain_id: int, address: str) -> str:
|
|
101
|
+
"""Build cache key for in-memory cache: chain_id:normalized_address."""
|
|
102
|
+
return f"{chain_id}:{_normalize_address(address)}"
|
|
103
|
+
|
|
104
|
+
|
|
36
105
|
# EIP-1967 storage slots for proxy detection
|
|
37
106
|
IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
|
|
38
107
|
BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
|
|
@@ -91,11 +160,16 @@ class ABIResolver:
|
|
|
91
160
|
"""Resolves contract ABIs with caching and proxy support.
|
|
92
161
|
|
|
93
162
|
Resolution order:
|
|
94
|
-
1. Check
|
|
95
|
-
2.
|
|
96
|
-
3.
|
|
97
|
-
4. Try
|
|
98
|
-
5.
|
|
163
|
+
1. Check in-memory TTLCache (fast-path, avoids blocking)
|
|
164
|
+
2. Check database cache (with TTL validation)
|
|
165
|
+
3. Detect if address is a proxy and resolve implementation
|
|
166
|
+
4. Try Etherscan API (short timeout to avoid blocking workers)
|
|
167
|
+
5. Try Sourcify as fallback
|
|
168
|
+
6. Raise ABINotFoundError if all fail (or return None for resolve_safe)
|
|
169
|
+
|
|
170
|
+
Policy (Plan 08):
|
|
171
|
+
- Critical path (check/build): Use local ABIs via interfaces or with_abi()
|
|
172
|
+
- Non-critical path (alerts/hooks): Runtime fetch is best-effort
|
|
99
173
|
|
|
100
174
|
Proxy detection:
|
|
101
175
|
- Checks EIP-1967 implementation slot
|
|
@@ -105,7 +179,7 @@ class ABIResolver:
|
|
|
105
179
|
|
|
106
180
|
def __init__(
|
|
107
181
|
self,
|
|
108
|
-
rpc:
|
|
182
|
+
rpc: ReadClient,
|
|
109
183
|
config: Config,
|
|
110
184
|
abi_cache: GlobalABICache | None = None,
|
|
111
185
|
) -> None:
|
|
@@ -115,7 +189,7 @@ class ABIResolver:
|
|
|
115
189
|
self._etherscan_api_url = ETHERSCAN_V2_API_URL
|
|
116
190
|
self._etherscan_api_key = os.environ.get("ETHERSCAN_API_KEY")
|
|
117
191
|
self._sourcify_enabled = True
|
|
118
|
-
self._request_timeout =
|
|
192
|
+
self._request_timeout = _FETCH_TIMEOUT
|
|
119
193
|
|
|
120
194
|
def resolve(
|
|
121
195
|
self,
|
|
@@ -137,24 +211,42 @@ class ABIResolver:
|
|
|
137
211
|
ABINotFoundError: If ABI cannot be resolved from any source
|
|
138
212
|
InvalidAddressError: If address is invalid
|
|
139
213
|
"""
|
|
140
|
-
# Validate
|
|
214
|
+
# Validate address format (don't poison cache with garbage)
|
|
215
|
+
if not _is_valid_address(address):
|
|
216
|
+
raise InvalidAddressError(address)
|
|
217
|
+
|
|
218
|
+
# Normalize for caching/lookup
|
|
141
219
|
try:
|
|
142
220
|
address = to_checksum_address(address)
|
|
143
221
|
except Exception:
|
|
144
222
|
raise InvalidAddressError(address)
|
|
145
223
|
|
|
146
224
|
chain_id = chain_id or self.config.chain_id
|
|
225
|
+
cache_key = _mem_cache_key(chain_id, address)
|
|
147
226
|
checked_sources: list[str] = []
|
|
148
227
|
|
|
149
|
-
# Step 1: Check cache (
|
|
228
|
+
# Step 0/1: Check database cache first (authoritative), then memory cache.
|
|
150
229
|
if not force_refresh:
|
|
151
230
|
cached = self.abi_cache.get_cached_abi(chain_id, address)
|
|
152
231
|
if cached and not self._is_cache_expired(cached.resolved_at):
|
|
232
|
+
# Populate in-memory cache for next time
|
|
233
|
+
abi = json.loads(cached.abi_json)
|
|
234
|
+
with _mem_cache_lock:
|
|
235
|
+
_mem_abi_cache[cache_key] = abi
|
|
153
236
|
return ResolvedABI(
|
|
154
237
|
address=address,
|
|
155
|
-
abi=
|
|
238
|
+
abi=abi,
|
|
156
239
|
source=cached.source,
|
|
157
240
|
)
|
|
241
|
+
with _mem_cache_lock:
|
|
242
|
+
if cache_key in _mem_abi_cache:
|
|
243
|
+
return ResolvedABI(
|
|
244
|
+
address=address,
|
|
245
|
+
abi=_mem_abi_cache[cache_key],
|
|
246
|
+
source="memory_cache",
|
|
247
|
+
)
|
|
248
|
+
if cache_key in _mem_abi_not_found:
|
|
249
|
+
raise ABINotFoundError(address, ["memory_cache"])
|
|
158
250
|
|
|
159
251
|
# Step 2: Check if proxy and resolve implementation
|
|
160
252
|
impl_address = self._resolve_proxy_implementation(chain_id, address)
|
|
@@ -176,6 +268,9 @@ class ABIResolver:
|
|
|
176
268
|
json.dumps(impl_abi),
|
|
177
269
|
"proxy_implementation",
|
|
178
270
|
)
|
|
271
|
+
# Populate in-memory cache
|
|
272
|
+
with _mem_cache_lock:
|
|
273
|
+
_mem_abi_cache[cache_key] = impl_abi
|
|
179
274
|
return ResolvedABI(
|
|
180
275
|
address=address,
|
|
181
276
|
abi=impl_abi,
|
|
@@ -195,10 +290,47 @@ class ABIResolver:
|
|
|
195
290
|
if abi:
|
|
196
291
|
source = checked_sources[-1] if checked_sources else "unknown"
|
|
197
292
|
self.abi_cache.set_cached_abi(chain_id, address, json.dumps(abi), source)
|
|
293
|
+
# Populate in-memory cache
|
|
294
|
+
with _mem_cache_lock:
|
|
295
|
+
_mem_abi_cache[cache_key] = abi
|
|
198
296
|
return ResolvedABI(address=address, abi=abi, source=source)
|
|
199
297
|
|
|
298
|
+
# Cache failure in memory (shorter TTL allows recovery)
|
|
299
|
+
with _mem_cache_lock:
|
|
300
|
+
_mem_abi_not_found[cache_key] = True
|
|
301
|
+
|
|
200
302
|
raise ABINotFoundError(address, checked_sources)
|
|
201
303
|
|
|
304
|
+
def resolve_safe(
|
|
305
|
+
self,
|
|
306
|
+
address: str,
|
|
307
|
+
chain_id: int | None = None,
|
|
308
|
+
) -> ResolvedABI | None:
|
|
309
|
+
"""Resolve ABI with fail-open behavior (returns None instead of raising).
|
|
310
|
+
|
|
311
|
+
Use this for non-critical paths where missing ABI should be handled
|
|
312
|
+
gracefully (e.g., event decoding in alerts, lifecycle hooks).
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
address: Contract address to resolve ABI for
|
|
316
|
+
chain_id: Chain ID (defaults to config.chain_id)
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
ResolvedABI if found, None if resolution fails for any reason
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
return self.resolve(address, chain_id=chain_id)
|
|
323
|
+
except (ABINotFoundError, InvalidAddressError):
|
|
324
|
+
return None
|
|
325
|
+
except Exception as e:
|
|
326
|
+
# Log unexpected errors but don't raise
|
|
327
|
+
logger.debug(
|
|
328
|
+
"abi_resolver.resolve_safe_error",
|
|
329
|
+
address=address[:50] if address else "None",
|
|
330
|
+
error=type(e).__name__,
|
|
331
|
+
)
|
|
332
|
+
return None
|
|
333
|
+
|
|
202
334
|
def _is_cache_expired(self, resolved_at: datetime) -> bool:
|
|
203
335
|
"""Check if cached entry is expired based on TTL."""
|
|
204
336
|
if self.config.abi_cache_ttl_seconds <= 0:
|
|
@@ -271,13 +403,15 @@ class ABIResolver:
|
|
|
271
403
|
final_impl = nested or impl_address
|
|
272
404
|
self.abi_cache.set_cached_proxy(chain_id, address, final_impl)
|
|
273
405
|
return final_impl
|
|
274
|
-
except
|
|
406
|
+
except (ValueError, TypeError) as e:
|
|
275
407
|
logger.debug("proxy.beacon_slot_error", address=address, error=str(e))
|
|
276
408
|
|
|
277
409
|
return None
|
|
278
410
|
|
|
279
411
|
def _read_storage_slot(self, address: str, slot: str) -> str | None:
|
|
280
412
|
"""Read a storage slot from a contract."""
|
|
413
|
+
from brawny._rpc.errors import RPCError
|
|
414
|
+
|
|
281
415
|
try:
|
|
282
416
|
result = self.rpc.get_storage_at(address, slot)
|
|
283
417
|
if result is None:
|
|
@@ -286,7 +420,7 @@ class ABIResolver:
|
|
|
286
420
|
if isinstance(result, bytes):
|
|
287
421
|
return "0x" + result.hex()
|
|
288
422
|
return result
|
|
289
|
-
except
|
|
423
|
+
except (RPCError, ValueError, TypeError):
|
|
290
424
|
return None
|
|
291
425
|
|
|
292
426
|
def _slot_to_address(self, slot_value: str) -> str | None:
|
|
@@ -310,7 +444,7 @@ class ABIResolver:
|
|
|
310
444
|
|
|
311
445
|
try:
|
|
312
446
|
return to_checksum_address("0x" + addr_hex)
|
|
313
|
-
except
|
|
447
|
+
except ValueError:
|
|
314
448
|
return None
|
|
315
449
|
|
|
316
450
|
def _read_beacon_implementation(self, beacon_address: str) -> str | None:
|
|
@@ -318,6 +452,8 @@ class ABIResolver:
|
|
|
318
452
|
|
|
319
453
|
Beacons typically have an implementation() function.
|
|
320
454
|
"""
|
|
455
|
+
from brawny._rpc.errors import RPCError
|
|
456
|
+
|
|
321
457
|
# implementation() function selector: 0x5c60da1b
|
|
322
458
|
try:
|
|
323
459
|
tx_params = {"to": beacon_address, "data": "0x5c60da1b"}
|
|
@@ -328,7 +464,7 @@ class ABIResolver:
|
|
|
328
464
|
result = "0x" + result.hex()
|
|
329
465
|
if result != "0x":
|
|
330
466
|
return self._slot_to_address(result)
|
|
331
|
-
except
|
|
467
|
+
except (RPCError, ValueError, TypeError) as e:
|
|
332
468
|
logger.debug(
|
|
333
469
|
"proxy.beacon_call_error",
|
|
334
470
|
beacon=beacon_address,
|
|
@@ -386,11 +522,8 @@ class ABIResolver:
|
|
|
386
522
|
params["apikey"] = self._etherscan_api_key
|
|
387
523
|
|
|
388
524
|
try:
|
|
389
|
-
|
|
390
|
-
api_url,
|
|
391
|
-
params=params,
|
|
392
|
-
timeout=self._request_timeout,
|
|
393
|
-
)
|
|
525
|
+
with allow_network_calls(reason="alerts"):
|
|
526
|
+
response = _client().get(api_url, params=params)
|
|
394
527
|
response.raise_for_status()
|
|
395
528
|
|
|
396
529
|
data = response.json()
|
|
@@ -403,13 +536,27 @@ class ABIResolver:
|
|
|
403
536
|
)
|
|
404
537
|
return abi
|
|
405
538
|
else:
|
|
406
|
-
logger.
|
|
407
|
-
"etherscan.
|
|
539
|
+
logger.warning(
|
|
540
|
+
"etherscan.abi_fetch_failed",
|
|
408
541
|
address=address,
|
|
409
542
|
chain_id=chain_id,
|
|
410
|
-
|
|
543
|
+
etherscan_status=data.get("status"),
|
|
544
|
+
etherscan_message=data.get("message"),
|
|
411
545
|
)
|
|
412
|
-
except
|
|
546
|
+
except httpx.TimeoutException:
|
|
547
|
+
logger.warning(
|
|
548
|
+
"etherscan.timeout",
|
|
549
|
+
address=address,
|
|
550
|
+
chain_id=chain_id,
|
|
551
|
+
)
|
|
552
|
+
except httpx.HTTPStatusError as e:
|
|
553
|
+
logger.warning(
|
|
554
|
+
"etherscan.http_error",
|
|
555
|
+
address=address,
|
|
556
|
+
chain_id=chain_id,
|
|
557
|
+
status=e.response.status_code,
|
|
558
|
+
)
|
|
559
|
+
except httpx.RequestError as e:
|
|
413
560
|
logger.warning(
|
|
414
561
|
"etherscan.request_error",
|
|
415
562
|
address=address,
|
|
@@ -451,17 +598,28 @@ class ABIResolver:
|
|
|
451
598
|
url = f"https://sourcify.dev/server/repository/contracts/{match_type}/{chain_id}/{address}/metadata.json"
|
|
452
599
|
|
|
453
600
|
try:
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
601
|
+
with allow_network_calls(reason="alerts"):
|
|
602
|
+
response = _client().get(url)
|
|
603
|
+
response.raise_for_status()
|
|
604
|
+
metadata = response.json()
|
|
605
|
+
if "output" in metadata and "abi" in metadata["output"]:
|
|
606
|
+
logger.debug(
|
|
607
|
+
"sourcify.abi_fetched",
|
|
608
|
+
address=address,
|
|
609
|
+
match_type=match_type,
|
|
610
|
+
)
|
|
611
|
+
return metadata["output"]["abi"]
|
|
612
|
+
except httpx.TimeoutException:
|
|
613
|
+
logger.debug("sourcify.timeout", address=address)
|
|
614
|
+
except httpx.HTTPStatusError as e:
|
|
615
|
+
# 404 is expected when contract not found
|
|
616
|
+
if e.response.status_code != 404:
|
|
617
|
+
logger.debug(
|
|
618
|
+
"sourcify.http_error",
|
|
619
|
+
address=address,
|
|
620
|
+
status=e.response.status_code,
|
|
621
|
+
)
|
|
622
|
+
except httpx.RequestError as e:
|
|
465
623
|
logger.debug("sourcify.request_error", address=address, error=str(e))
|
|
466
624
|
except json.JSONDecodeError as e:
|
|
467
625
|
logger.debug("sourcify.json_error", address=address, error=str(e))
|
|
@@ -471,6 +629,8 @@ class ABIResolver:
|
|
|
471
629
|
def clear_cache(self, address: str, chain_id: int | None = None) -> bool:
|
|
472
630
|
"""Clear cached ABI and proxy resolution for an address.
|
|
473
631
|
|
|
632
|
+
Clears both in-memory and database caches.
|
|
633
|
+
|
|
474
634
|
Args:
|
|
475
635
|
address: Contract address
|
|
476
636
|
chain_id: Chain ID (defaults to config.chain_id)
|
|
@@ -484,6 +644,13 @@ class ABIResolver:
|
|
|
484
644
|
except Exception:
|
|
485
645
|
raise InvalidAddressError(address)
|
|
486
646
|
|
|
647
|
+
# Clear in-memory cache
|
|
648
|
+
cache_key = _mem_cache_key(chain_id, address)
|
|
649
|
+
with _mem_cache_lock:
|
|
650
|
+
_mem_abi_cache.pop(cache_key, None)
|
|
651
|
+
_mem_abi_not_found.pop(cache_key, None)
|
|
652
|
+
|
|
653
|
+
# Clear database cache
|
|
487
654
|
abi_cleared = self.abi_cache.clear_cached_abi(chain_id, address)
|
|
488
655
|
proxy_cleared = self.abi_cache.clear_cached_proxy(chain_id, address)
|
|
489
656
|
|
|
@@ -497,6 +664,8 @@ class ABIResolver:
|
|
|
497
664
|
) -> None:
|
|
498
665
|
"""Manually set ABI for a contract.
|
|
499
666
|
|
|
667
|
+
Populates both in-memory and database caches.
|
|
668
|
+
|
|
500
669
|
Args:
|
|
501
670
|
address: Contract address
|
|
502
671
|
abi: ABI to cache
|
|
@@ -508,6 +677,13 @@ class ABIResolver:
|
|
|
508
677
|
except Exception:
|
|
509
678
|
raise InvalidAddressError(address)
|
|
510
679
|
|
|
680
|
+
# Populate in-memory cache
|
|
681
|
+
cache_key = _mem_cache_key(chain_id, address)
|
|
682
|
+
with _mem_cache_lock:
|
|
683
|
+
_mem_abi_cache[cache_key] = abi
|
|
684
|
+
_mem_abi_not_found.pop(cache_key, None) # Clear any failure marker
|
|
685
|
+
|
|
686
|
+
# Populate database cache
|
|
511
687
|
self.abi_cache.set_cached_abi(chain_id, address, json.dumps(abi), "manual")
|
|
512
688
|
logger.info("abi.manual_set", address=address, chain_id=chain_id)
|
|
513
689
|
|
brawny/alerts/base.py
CHANGED
|
@@ -122,7 +122,7 @@ def explorer_link(
|
|
|
122
122
|
chain_id: int = 1,
|
|
123
123
|
label: str | None = None,
|
|
124
124
|
) -> str:
|
|
125
|
-
"""Create a
|
|
125
|
+
"""Create a Markdown explorer link with emoji.
|
|
126
126
|
|
|
127
127
|
Automatically detects if input is a tx hash or address.
|
|
128
128
|
|
|
@@ -132,7 +132,7 @@ def explorer_link(
|
|
|
132
132
|
label: Custom label (default: "🔗 View on Explorer")
|
|
133
133
|
|
|
134
134
|
Returns:
|
|
135
|
-
|
|
135
|
+
Markdown formatted link like "[🔗 View on Explorer](url)"
|
|
136
136
|
|
|
137
137
|
Example:
|
|
138
138
|
>>> explorer_link("0xabc123...")
|
brawny/alerts/contracts.py
CHANGED
|
@@ -21,9 +21,11 @@ For events, use ctx.events (brownie-compatible):
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
23
|
import re
|
|
24
|
+
import threading
|
|
24
25
|
import time
|
|
25
26
|
from typing import TYPE_CHECKING, Any
|
|
26
27
|
|
|
28
|
+
from cachetools import TTLCache
|
|
27
29
|
from eth_abi import decode as abi_decode
|
|
28
30
|
from eth_utils import function_signature_to_4byte_selector, to_checksum_address
|
|
29
31
|
|
|
@@ -40,11 +42,37 @@ from brawny.alerts.function_caller import (
|
|
|
40
42
|
OverloadedFunction,
|
|
41
43
|
)
|
|
42
44
|
from brawny.db.global_cache import GlobalABICache
|
|
45
|
+
from brawny.logging import get_logger
|
|
46
|
+
|
|
47
|
+
logger = get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
# Warn-once cache: don't spam logs for same missing ABI
|
|
50
|
+
# Multi-threaded access - protected by lock
|
|
51
|
+
_warned_addresses: TTLCache[str, bool] = TTLCache(maxsize=1_000, ttl=3600)
|
|
52
|
+
_warned_lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_for_warn(address: str) -> str:
|
|
56
|
+
"""Normalize address for warn-once cache (matches ABIResolver normalization)."""
|
|
57
|
+
addr = address.lower()
|
|
58
|
+
if not addr.startswith("0x"):
|
|
59
|
+
addr = "0x" + addr
|
|
60
|
+
return addr
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _warn_once(address: str) -> bool:
|
|
64
|
+
"""Return True if we should warn (first time for this address)."""
|
|
65
|
+
addr = _normalize_for_warn(address)
|
|
66
|
+
with _warned_lock:
|
|
67
|
+
if addr in _warned_addresses:
|
|
68
|
+
return False
|
|
69
|
+
_warned_addresses[addr] = True
|
|
70
|
+
return True
|
|
43
71
|
|
|
44
72
|
if TYPE_CHECKING:
|
|
45
73
|
from brawny.config import Config
|
|
46
74
|
from brawny.jobs.base import TxReceipt
|
|
47
|
-
from brawny._rpc.
|
|
75
|
+
from brawny._rpc.clients import BroadcastClient
|
|
48
76
|
|
|
49
77
|
|
|
50
78
|
class ContractSystem:
|
|
@@ -53,14 +81,14 @@ class ContractSystem:
|
|
|
53
81
|
Uses global ABI cache at ~/.brawny/abi_cache.db for persistent storage.
|
|
54
82
|
"""
|
|
55
83
|
|
|
56
|
-
def __init__(self, rpc: "
|
|
84
|
+
def __init__(self, rpc: "BroadcastClient", config: "Config") -> None:
|
|
57
85
|
self._rpc = rpc
|
|
58
86
|
self._config = config
|
|
59
87
|
self._abi_cache = GlobalABICache()
|
|
60
88
|
self._resolver = None
|
|
61
89
|
|
|
62
90
|
@property
|
|
63
|
-
def rpc(self) -> "
|
|
91
|
+
def rpc(self) -> "BroadcastClient":
|
|
64
92
|
return self._rpc
|
|
65
93
|
|
|
66
94
|
@property
|
|
@@ -136,6 +164,7 @@ class ContractHandle:
|
|
|
136
164
|
self._job_id = job_id
|
|
137
165
|
self._hook = hook
|
|
138
166
|
self._abi_list = abi
|
|
167
|
+
self._abi_source: str | None = "manual" if abi is not None else None
|
|
139
168
|
self._functions: dict[str, list[FunctionABI]] | None = None
|
|
140
169
|
|
|
141
170
|
@property
|
|
@@ -150,13 +179,33 @@ class ContractHandle:
|
|
|
150
179
|
return self._abi_list # type: ignore
|
|
151
180
|
|
|
152
181
|
def _ensure_abi(self) -> None:
|
|
153
|
-
"""Ensure ABI is loaded.
|
|
182
|
+
"""Ensure ABI is loaded, fetching if needed.
|
|
183
|
+
|
|
184
|
+
Uses resolve_safe() which returns None instead of raising on failure.
|
|
185
|
+
If ABI cannot be resolved, logs warning once but doesn't raise.
|
|
186
|
+
Callers should check self._abi_list before using it.
|
|
187
|
+
"""
|
|
154
188
|
if self._abi_list is not None:
|
|
155
189
|
return
|
|
156
190
|
|
|
157
191
|
resolver = self._system.resolver()
|
|
158
|
-
resolved = resolver.
|
|
159
|
-
|
|
192
|
+
resolved = resolver.resolve_safe(self._address)
|
|
193
|
+
|
|
194
|
+
if resolved is not None:
|
|
195
|
+
self._abi_list = resolved.abi
|
|
196
|
+
self._abi_source = resolved.source
|
|
197
|
+
else:
|
|
198
|
+
# Always log at INFO level so resolution failures are visible
|
|
199
|
+
logger.info(
|
|
200
|
+
"contract.abi_resolution_failed",
|
|
201
|
+
address=self._address,
|
|
202
|
+
)
|
|
203
|
+
if _warn_once(self._address):
|
|
204
|
+
logger.warning(
|
|
205
|
+
"contract.abi_not_found",
|
|
206
|
+
address=self._address,
|
|
207
|
+
hint="Use ctx.contracts.with_abi() or add to interfaces/",
|
|
208
|
+
)
|
|
160
209
|
|
|
161
210
|
def _ensure_functions_parsed(self) -> None:
|
|
162
211
|
"""Ensure function ABIs are parsed."""
|
|
@@ -166,7 +215,11 @@ class ContractHandle:
|
|
|
166
215
|
self._ensure_abi()
|
|
167
216
|
self._functions = {}
|
|
168
217
|
|
|
169
|
-
|
|
218
|
+
# Handle case where ABI resolution failed
|
|
219
|
+
if self._abi_list is None:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
for item in self._abi_list:
|
|
170
223
|
if item.get("type") != "function":
|
|
171
224
|
continue
|
|
172
225
|
|
|
@@ -213,7 +266,13 @@ class ContractHandle:
|
|
|
213
266
|
|
|
214
267
|
if name not in self._functions:
|
|
215
268
|
available = list(self._functions.keys()) if self._functions else []
|
|
216
|
-
raise FunctionNotFoundError(
|
|
269
|
+
raise FunctionNotFoundError(
|
|
270
|
+
name,
|
|
271
|
+
self._address,
|
|
272
|
+
available_functions=available,
|
|
273
|
+
abi_resolved=self._abi_list is not None,
|
|
274
|
+
abi_source=self._abi_source,
|
|
275
|
+
)
|
|
217
276
|
|
|
218
277
|
overloads = self._functions[name]
|
|
219
278
|
|
|
@@ -259,14 +318,22 @@ class ContractHandle:
|
|
|
259
318
|
return ExplicitFunctionCaller(self, func)
|
|
260
319
|
|
|
261
320
|
raise FunctionNotFoundError(
|
|
262
|
-
signature,
|
|
321
|
+
signature,
|
|
322
|
+
self._address,
|
|
323
|
+
available_functions=self._get_all_signatures(),
|
|
324
|
+
abi_resolved=self._abi_list is not None,
|
|
325
|
+
abi_source=self._abi_source,
|
|
263
326
|
)
|
|
264
327
|
else:
|
|
265
328
|
# Just a name - must have exactly one overload
|
|
266
329
|
name = signature
|
|
267
330
|
if name not in self._functions:
|
|
268
331
|
raise FunctionNotFoundError(
|
|
269
|
-
name,
|
|
332
|
+
name,
|
|
333
|
+
self._address,
|
|
334
|
+
available_functions=list(self._functions.keys()),
|
|
335
|
+
abi_resolved=self._abi_list is not None,
|
|
336
|
+
abi_source=self._abi_source,
|
|
270
337
|
)
|
|
271
338
|
|
|
272
339
|
overloads = self._functions[name]
|
brawny/alerts/errors.py
CHANGED
|
@@ -153,26 +153,53 @@ class OverloadMatchError(DXError):
|
|
|
153
153
|
|
|
154
154
|
|
|
155
155
|
class FunctionNotFoundError(DXError):
|
|
156
|
-
"""Raised when function is not found in contract ABI.
|
|
156
|
+
"""Raised when function is not found in contract ABI.
|
|
157
|
+
|
|
158
|
+
Includes context about ABI resolution status to help diagnose
|
|
159
|
+
whether the ABI was never fetched vs fetched but missing the function.
|
|
160
|
+
"""
|
|
157
161
|
|
|
158
162
|
def __init__(
|
|
159
163
|
self,
|
|
160
164
|
function_name: str,
|
|
161
165
|
address: str,
|
|
162
166
|
available_functions: list[str] | None = None,
|
|
167
|
+
abi_resolved: bool | None = None,
|
|
168
|
+
abi_source: str | None = None,
|
|
163
169
|
) -> None:
|
|
164
170
|
self.function_name = function_name
|
|
165
171
|
self.address = address
|
|
166
172
|
self.available_functions = available_functions
|
|
167
|
-
|
|
173
|
+
self.abi_resolved = abi_resolved
|
|
174
|
+
self.abi_source = abi_source
|
|
175
|
+
|
|
176
|
+
# Build message based on ABI resolution status
|
|
177
|
+
if abi_resolved is False:
|
|
178
|
+
# ABI fetch failed completely
|
|
179
|
+
msg = (
|
|
180
|
+
f"Function '{function_name}' not found for {address}: "
|
|
181
|
+
f"ABI resolution failed. Check logs for etherscan.abi_fetch_failed "
|
|
182
|
+
f"or use ctx.contracts.with_abi() to provide ABI manually."
|
|
183
|
+
)
|
|
184
|
+
elif available_functions:
|
|
185
|
+
# ABI resolved but function not in it
|
|
168
186
|
available_str = ", ".join(available_functions[:10])
|
|
169
187
|
if len(available_functions) > 10:
|
|
170
188
|
available_str += f" ... ({len(available_functions) - 10} more)"
|
|
189
|
+
source_hint = f" (source: {abi_source})" if abi_source else ""
|
|
171
190
|
msg = (
|
|
172
|
-
f"Function '{function_name}' not found in ABI for {address}. "
|
|
191
|
+
f"Function '{function_name}' not found in ABI for {address}{source_hint}. "
|
|
173
192
|
f"Available functions: [{available_str}]"
|
|
174
193
|
)
|
|
194
|
+
elif abi_resolved is True:
|
|
195
|
+
# ABI resolved but empty (no functions)
|
|
196
|
+
source_hint = f" (source: {abi_source})" if abi_source else ""
|
|
197
|
+
msg = (
|
|
198
|
+
f"Function '{function_name}' not found for {address}: "
|
|
199
|
+
f"ABI was resolved{source_hint} but contains no functions."
|
|
200
|
+
)
|
|
175
201
|
else:
|
|
202
|
+
# Legacy fallback (no resolution status provided)
|
|
176
203
|
msg = f"Function '{function_name}' not found in ABI for {address}."
|
|
177
204
|
super().__init__(msg)
|
|
178
205
|
|