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.
Files changed (135) hide show
  1. brawny/__init__.py +2 -0
  2. brawny/_context.py +5 -5
  3. brawny/_rpc/__init__.py +36 -12
  4. brawny/_rpc/broadcast.py +14 -13
  5. brawny/_rpc/caller.py +243 -0
  6. brawny/_rpc/client.py +539 -0
  7. brawny/_rpc/clients.py +11 -11
  8. brawny/_rpc/context.py +23 -0
  9. brawny/_rpc/errors.py +465 -31
  10. brawny/_rpc/gas.py +7 -6
  11. brawny/_rpc/pool.py +18 -0
  12. brawny/_rpc/retry.py +266 -0
  13. brawny/_rpc/retry_policy.py +81 -0
  14. brawny/accounts.py +28 -9
  15. brawny/alerts/__init__.py +15 -18
  16. brawny/alerts/abi_resolver.py +212 -36
  17. brawny/alerts/base.py +2 -2
  18. brawny/alerts/contracts.py +77 -10
  19. brawny/alerts/errors.py +30 -3
  20. brawny/alerts/events.py +38 -5
  21. brawny/alerts/health.py +19 -13
  22. brawny/alerts/send.py +513 -55
  23. brawny/api.py +39 -11
  24. brawny/assets/AGENTS.md +325 -0
  25. brawny/async_runtime.py +48 -0
  26. brawny/chain.py +3 -3
  27. brawny/cli/commands/__init__.py +2 -0
  28. brawny/cli/commands/console.py +69 -19
  29. brawny/cli/commands/contract.py +2 -2
  30. brawny/cli/commands/controls.py +121 -0
  31. brawny/cli/commands/health.py +2 -2
  32. brawny/cli/commands/job_dev.py +6 -5
  33. brawny/cli/commands/jobs.py +99 -2
  34. brawny/cli/commands/maintenance.py +13 -29
  35. brawny/cli/commands/migrate.py +1 -0
  36. brawny/cli/commands/run.py +10 -3
  37. brawny/cli/commands/script.py +8 -3
  38. brawny/cli/commands/signer.py +143 -26
  39. brawny/cli/helpers.py +0 -3
  40. brawny/cli_templates.py +25 -349
  41. brawny/config/__init__.py +4 -1
  42. brawny/config/models.py +43 -57
  43. brawny/config/parser.py +268 -57
  44. brawny/config/validation.py +52 -15
  45. brawny/daemon/context.py +4 -2
  46. brawny/daemon/core.py +185 -63
  47. brawny/daemon/loops.py +166 -98
  48. brawny/daemon/supervisor.py +261 -0
  49. brawny/db/__init__.py +14 -26
  50. brawny/db/base.py +248 -151
  51. brawny/db/global_cache.py +11 -1
  52. brawny/db/migrate.py +175 -28
  53. brawny/db/migrations/001_init.sql +4 -3
  54. brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
  55. brawny/db/migrations/011_add_job_logs.sql +1 -2
  56. brawny/db/migrations/012_add_claimed_by.sql +2 -2
  57. brawny/db/migrations/013_attempt_unique.sql +10 -0
  58. brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
  59. brawny/db/migrations/015_add_signer_alias.sql +14 -0
  60. brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
  61. brawny/db/migrations/017_add_job_drain.sql +6 -0
  62. brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
  63. brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
  64. brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
  65. brawny/db/ops/__init__.py +3 -25
  66. brawny/db/ops/logs.py +1 -2
  67. brawny/db/queries.py +47 -91
  68. brawny/db/serialized.py +65 -0
  69. brawny/db/sqlite/__init__.py +1001 -0
  70. brawny/db/sqlite/connection.py +231 -0
  71. brawny/db/sqlite/execute.py +116 -0
  72. brawny/db/sqlite/mappers.py +190 -0
  73. brawny/db/sqlite/repos/attempts.py +372 -0
  74. brawny/db/sqlite/repos/block_state.py +102 -0
  75. brawny/db/sqlite/repos/cache.py +104 -0
  76. brawny/db/sqlite/repos/intents.py +1021 -0
  77. brawny/db/sqlite/repos/jobs.py +200 -0
  78. brawny/db/sqlite/repos/maintenance.py +182 -0
  79. brawny/db/sqlite/repos/signers_nonces.py +566 -0
  80. brawny/db/sqlite/tx.py +119 -0
  81. brawny/http.py +194 -0
  82. brawny/invariants.py +11 -24
  83. brawny/jobs/base.py +8 -0
  84. brawny/jobs/job_validation.py +2 -1
  85. brawny/keystore.py +83 -7
  86. brawny/lifecycle.py +64 -12
  87. brawny/logging.py +0 -2
  88. brawny/metrics.py +84 -12
  89. brawny/model/contexts.py +111 -9
  90. brawny/model/enums.py +1 -0
  91. brawny/model/errors.py +18 -0
  92. brawny/model/types.py +47 -131
  93. brawny/network_guard.py +133 -0
  94. brawny/networks/__init__.py +5 -5
  95. brawny/networks/config.py +1 -7
  96. brawny/networks/manager.py +14 -11
  97. brawny/runtime_controls.py +74 -0
  98. brawny/scheduler/poller.py +11 -7
  99. brawny/scheduler/reorg.py +95 -39
  100. brawny/scheduler/runner.py +442 -168
  101. brawny/scheduler/shutdown.py +3 -3
  102. brawny/script_tx.py +3 -3
  103. brawny/telegram.py +53 -7
  104. brawny/testing.py +1 -0
  105. brawny/timeout.py +38 -0
  106. brawny/tx/executor.py +922 -308
  107. brawny/tx/intent.py +54 -16
  108. brawny/tx/monitor.py +31 -12
  109. brawny/tx/nonce.py +212 -90
  110. brawny/tx/replacement.py +69 -18
  111. brawny/tx/retry_policy.py +24 -0
  112. brawny/tx/stages/types.py +75 -0
  113. brawny/types.py +18 -0
  114. brawny/utils.py +41 -0
  115. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
  116. brawny-0.1.22.dist-info/RECORD +163 -0
  117. brawny/_rpc/manager.py +0 -982
  118. brawny/_rpc/selector.py +0 -156
  119. brawny/db/base_new.py +0 -165
  120. brawny/db/mappers.py +0 -182
  121. brawny/db/migrations/008_add_transactions.sql +0 -72
  122. brawny/db/ops/attempts.py +0 -108
  123. brawny/db/ops/blocks.py +0 -83
  124. brawny/db/ops/cache.py +0 -93
  125. brawny/db/ops/intents.py +0 -296
  126. brawny/db/ops/jobs.py +0 -110
  127. brawny/db/ops/nonces.py +0 -322
  128. brawny/db/postgres.py +0 -2535
  129. brawny/db/postgres_new.py +0 -196
  130. brawny/db/sqlite.py +0 -2733
  131. brawny/db/sqlite_new.py +0 -191
  132. brawny-0.1.13.dist-info/RECORD +0 -141
  133. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
  134. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
  135. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
@@ -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 requests
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.manager import RPCManager
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 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
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: RPCManager,
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 = 10.0
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 and normalize address
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 (unless force refresh)
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=json.loads(cached.abi_json),
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 Exception as e:
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 Exception:
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 Exception:
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 Exception as e:
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
- response = requests.get(
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.debug(
407
- "etherscan.abi_not_found",
539
+ logger.warning(
540
+ "etherscan.abi_fetch_failed",
408
541
  address=address,
409
542
  chain_id=chain_id,
410
- message=data.get("message"),
543
+ etherscan_status=data.get("status"),
544
+ etherscan_message=data.get("message"),
411
545
  )
412
- except requests.RequestException as e:
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
- 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:
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 MarkdownV2 explorer link with emoji.
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
- MarkdownV2 formatted link like "[🔗 View on Explorer](url)"
135
+ Markdown formatted link like "[🔗 View on Explorer](url)"
136
136
 
137
137
  Example:
138
138
  >>> explorer_link("0xabc123...")
@@ -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.manager import RPCManager
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: "RPCManager", config: "Config") -> None:
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) -> "RPCManager":
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.resolve(self._address)
159
- self._abi_list = resolved.abi
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
- for item in self._abi_list: # type: ignore
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(name, self._address, available)
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, self._address, self._get_all_signatures()
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, self._address, list(self._functions.keys())
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
- if available_functions:
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