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
brawny/_rpc/client.py ADDED
@@ -0,0 +1,539 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import TYPE_CHECKING, Any, Callable
5
+
6
+ from brawny._rpc.caller import Caller
7
+ from brawny._rpc.pool import EndpointPool
8
+ from brawny._rpc.retry import call_with_retries
9
+ from brawny._rpc.retry_policy import RetryPolicy, policy_from_values
10
+ from brawny._rpc.errors import (
11
+ RPCDeadlineExceeded,
12
+ RPCError,
13
+ RPCFatalError,
14
+ RPCRecoverableError,
15
+ )
16
+ from brawny.logging import get_logger
17
+ from brawny.timeout import Deadline
18
+ from brawny.model.errors import SimulationNetworkError, SimulationReverted
19
+
20
+ if TYPE_CHECKING:
21
+ from brawny.config import Config
22
+ from brawny._rpc.gas import GasQuote, GasQuoteCache
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def _rpc_host(url: str) -> str:
28
+ try:
29
+ split = url.split("://", 1)[1]
30
+ except IndexError:
31
+ return "unknown"
32
+ host = split.split("/", 1)[0]
33
+ host = host.split("@", 1)[-1]
34
+ host = host.split(":", 1)[0]
35
+ return host or "unknown"
36
+
37
+
38
+ def _normalize_tx_hash(tx_hash: str | bytes | bytearray) -> str:
39
+ if isinstance(tx_hash, (bytes, bytearray)):
40
+ return f"0x{bytes(tx_hash).hex()}"
41
+ if isinstance(tx_hash, str) and (tx_hash.startswith("b'") or tx_hash.startswith('b"')):
42
+ try:
43
+ import ast
44
+
45
+ value = ast.literal_eval(tx_hash)
46
+ if isinstance(value, (bytes, bytearray)):
47
+ return f"0x{bytes(value).hex()}"
48
+ except (SyntaxError, ValueError):
49
+ pass
50
+ return tx_hash
51
+
52
+
53
+ class ReadClient:
54
+ """Read RPC client using EndpointPool + Caller + call_with_retries."""
55
+
56
+ def __init__(
57
+ self,
58
+ endpoints: list[str],
59
+ timeout_seconds: float = 30.0,
60
+ max_retries: int = 3,
61
+ retry_backoff_base: float = 1.0,
62
+ retry_policy: RetryPolicy | None = None,
63
+ chain_id: int | None = None,
64
+ gas_refresh_seconds: int = 15,
65
+ log_init: bool = True,
66
+ request_id_factory: Callable[[], str] | None = None,
67
+ bound: bool = False,
68
+ ) -> None:
69
+ if not endpoints:
70
+ raise ValueError("At least one RPC endpoint is required")
71
+
72
+ self._pool = EndpointPool(endpoints)
73
+ self._timeout = timeout_seconds
74
+ if retry_policy is None:
75
+ retry_policy = policy_from_values(
76
+ "DEFAULT",
77
+ max_attempts=max_retries,
78
+ base_backoff_seconds=retry_backoff_base,
79
+ )
80
+ self._retry_policy = retry_policy
81
+ self._chain_id = chain_id
82
+ self._gas_refresh_seconds = gas_refresh_seconds
83
+ self._gas_cache: "GasQuoteCache | None" = None
84
+ self._caller = Caller(self._pool.endpoints, timeout_seconds, chain_id)
85
+ self._request_id_factory = request_id_factory or _default_request_id
86
+ self._bound = bound
87
+
88
+ hosts = []
89
+ for ep in self._pool.endpoints:
90
+ h = _rpc_host(ep)
91
+ if h not in ("unknown", "other"):
92
+ hosts.append(h)
93
+ self._allowed_hosts = frozenset(hosts)
94
+
95
+ if log_init:
96
+ logger.info(
97
+ "rpc.client.initialized",
98
+ endpoints=len(endpoints),
99
+ timeout=timeout_seconds,
100
+ max_retries=retry_policy.max_attempts,
101
+ )
102
+
103
+ @classmethod
104
+ def from_config(cls, config: Config) -> "ReadClient":
105
+ from brawny.config.routing import resolve_default_group
106
+ from brawny._rpc.retry_policy import fast_read_policy
107
+
108
+ default_group = resolve_default_group(config)
109
+ endpoints = config.rpc_groups[default_group].endpoints
110
+ return cls(
111
+ endpoints=endpoints,
112
+ timeout_seconds=config.rpc_timeout_seconds,
113
+ max_retries=config.rpc_max_retries,
114
+ retry_backoff_base=config.rpc_retry_backoff_base,
115
+ retry_policy=fast_read_policy(config),
116
+ chain_id=config.chain_id,
117
+ gas_refresh_seconds=config.gas_refresh_seconds,
118
+ )
119
+
120
+ @property
121
+ def web3(self):
122
+ endpoint = self._pool.order_endpoints()[0]
123
+ return self._caller.get_web3(endpoint, self._timeout)
124
+
125
+ @property
126
+ def gas(self) -> "GasQuoteCache":
127
+ if self._gas_cache is None:
128
+ from brawny._rpc.gas import GasQuoteCache
129
+
130
+ self._gas_cache = GasQuoteCache(
131
+ self,
132
+ ttl_seconds=self._gas_refresh_seconds,
133
+ )
134
+ return self._gas_cache
135
+
136
+ async def gas_quote(self) -> "GasQuote":
137
+ return await self.gas.get_quote()
138
+
139
+ def gas_quote_sync(self, deadline: Deadline | None = None) -> "GasQuote | None":
140
+ return self.gas.get_quote_sync(deadline=deadline)
141
+
142
+ def call(
143
+ self,
144
+ method: str,
145
+ *args: Any,
146
+ timeout: float | None = None,
147
+ deadline: Deadline | None = None,
148
+ block_identifier: int | str = "latest",
149
+ ) -> Any:
150
+ timeout = timeout or self._timeout
151
+ request_id = self._request_id_factory()
152
+ return call_with_retries(
153
+ self._pool,
154
+ self._caller,
155
+ self._retry_policy,
156
+ method,
157
+ args,
158
+ timeout=timeout,
159
+ deadline=deadline,
160
+ block_identifier=block_identifier,
161
+ chain_id=self._chain_id,
162
+ request_id=request_id,
163
+ bound=self._bound,
164
+ allowed_hosts=self._allowed_hosts,
165
+ )
166
+
167
+ def with_retry(
168
+ self,
169
+ fn: Callable[[Any], Any],
170
+ timeout: float | None = None,
171
+ deadline: Deadline | None = None,
172
+ ) -> Any:
173
+ timeout = timeout or self._timeout
174
+ request_id = self._request_id_factory()
175
+
176
+ class _FnCaller:
177
+ def __init__(self, caller: Caller, fn: Callable[[Any], Any]) -> None:
178
+ self._caller = caller
179
+ self._fn = fn
180
+
181
+ def call(
182
+ self,
183
+ endpoint: str,
184
+ method: str,
185
+ _args: tuple[Any, ...],
186
+ *,
187
+ timeout: float,
188
+ deadline: Deadline | None,
189
+ block_identifier: int | str,
190
+ ) -> Any:
191
+ return self._caller.call_with_web3(
192
+ endpoint,
193
+ timeout=timeout,
194
+ deadline=deadline,
195
+ method=method,
196
+ fn=self._fn,
197
+ )
198
+
199
+ return call_with_retries(
200
+ self._pool,
201
+ _FnCaller(self._caller, fn), # type: ignore[arg-type]
202
+ self._retry_policy,
203
+ "with_retry",
204
+ (),
205
+ timeout=timeout,
206
+ deadline=deadline,
207
+ block_identifier="latest",
208
+ chain_id=self._chain_id,
209
+ request_id=request_id,
210
+ bound=self._bound,
211
+ allowed_hosts=self._allowed_hosts,
212
+ )
213
+
214
+ def get_block_number(
215
+ self,
216
+ timeout: float | None = None,
217
+ deadline: Deadline | None = None,
218
+ ) -> int:
219
+ return self.call("eth_blockNumber", timeout=timeout, deadline=deadline)
220
+
221
+ def get_block(
222
+ self,
223
+ block_identifier: int | str = "latest",
224
+ full_transactions: bool = False,
225
+ timeout: float | None = None,
226
+ deadline: Deadline | None = None,
227
+ ) -> dict[str, Any]:
228
+ return self.call(
229
+ "eth_getBlockByNumber",
230
+ block_identifier,
231
+ full_transactions,
232
+ timeout=timeout,
233
+ deadline=deadline,
234
+ )
235
+
236
+ def get_transaction_count(
237
+ self,
238
+ address: str,
239
+ block_identifier: int | str = "pending",
240
+ timeout: float | None = None,
241
+ deadline: Deadline | None = None,
242
+ ) -> int:
243
+ return self.call(
244
+ "eth_getTransactionCount",
245
+ address,
246
+ block_identifier,
247
+ timeout=timeout,
248
+ deadline=deadline,
249
+ )
250
+
251
+ def get_transaction_receipt(
252
+ self,
253
+ tx_hash: str,
254
+ timeout: float | None = None,
255
+ deadline: Deadline | None = None,
256
+ ) -> dict[str, Any] | None:
257
+ tx_hash = _normalize_tx_hash(tx_hash)
258
+ return self.call("eth_getTransactionReceipt", tx_hash, timeout=timeout, deadline=deadline)
259
+
260
+ def get_transaction_by_hash(
261
+ self,
262
+ tx_hash: str,
263
+ timeout: float | None = None,
264
+ deadline: Deadline | None = None,
265
+ ) -> dict[str, Any] | None:
266
+ tx_hash = _normalize_tx_hash(tx_hash)
267
+ return self.call("eth_getTransactionByHash", tx_hash, timeout=timeout, deadline=deadline)
268
+
269
+ def send_raw_transaction(
270
+ self,
271
+ raw_tx: bytes,
272
+ timeout: float | None = None,
273
+ deadline: Deadline | None = None,
274
+ ) -> tuple[str, str]:
275
+ timeout = timeout or self._timeout
276
+ request_id = self._request_id_factory()
277
+ tx_hash, endpoint = call_with_retries(
278
+ self._pool,
279
+ self._caller,
280
+ self._retry_policy,
281
+ "eth_sendRawTransaction",
282
+ (raw_tx,),
283
+ timeout=timeout,
284
+ deadline=deadline,
285
+ block_identifier="latest",
286
+ chain_id=self._chain_id,
287
+ request_id=request_id,
288
+ bound=self._bound,
289
+ allowed_hosts=self._allowed_hosts,
290
+ return_endpoint=True,
291
+ )
292
+ return tx_hash, endpoint
293
+
294
+ def estimate_gas(
295
+ self,
296
+ tx_params: dict[str, Any],
297
+ block_identifier: int | str = "latest",
298
+ timeout: float | None = None,
299
+ deadline: Deadline | None = None,
300
+ ) -> int:
301
+ return self.call(
302
+ "eth_estimateGas",
303
+ tx_params,
304
+ timeout=timeout,
305
+ deadline=deadline,
306
+ block_identifier=block_identifier,
307
+ )
308
+
309
+ def eth_call(
310
+ self,
311
+ tx_params: dict[str, Any],
312
+ block_identifier: int | str = "latest",
313
+ timeout: float | None = None,
314
+ deadline: Deadline | None = None,
315
+ ) -> bytes:
316
+ return self.call(
317
+ "eth_call",
318
+ tx_params,
319
+ block_identifier,
320
+ timeout=timeout,
321
+ deadline=deadline,
322
+ )
323
+
324
+ def get_storage_at(
325
+ self,
326
+ address: str,
327
+ slot: int,
328
+ block_identifier: int | str = "latest",
329
+ timeout: float | None = None,
330
+ deadline: Deadline | None = None,
331
+ ) -> bytes:
332
+ return self.call(
333
+ "eth_getStorageAt",
334
+ address,
335
+ slot,
336
+ block_identifier,
337
+ timeout=timeout,
338
+ deadline=deadline,
339
+ )
340
+
341
+ def get_chain_id(self, timeout: float | None = None, deadline: Deadline | None = None) -> int:
342
+ return self.call("eth_chainId", timeout=timeout, deadline=deadline)
343
+
344
+ def get_gas_price(self, timeout: float | None = None, deadline: Deadline | None = None) -> int:
345
+ return self.call("eth_gasPrice", timeout=timeout, deadline=deadline)
346
+
347
+ def get_base_fee(
348
+ self, timeout: float | None = None, deadline: Deadline | None = None
349
+ ) -> int:
350
+ block = self.get_block("latest", timeout=timeout, deadline=deadline)
351
+ return int(block.get("baseFeePerGas", 0))
352
+
353
+ def get_balance(
354
+ self,
355
+ address: str,
356
+ block_identifier: int | str = "latest",
357
+ timeout: float | None = None,
358
+ deadline: Deadline | None = None,
359
+ ) -> int:
360
+ return self.call(
361
+ "eth_getBalance",
362
+ address,
363
+ block_identifier,
364
+ timeout=timeout,
365
+ deadline=deadline,
366
+ )
367
+
368
+ def simulate_transaction(
369
+ self,
370
+ tx: dict[str, Any],
371
+ rpc_url: str | None = None,
372
+ timeout: float | None = None,
373
+ deadline: Deadline | None = None,
374
+ ) -> dict[str, Any]:
375
+ if deadline is not None and deadline.expired():
376
+ raise SimulationNetworkError("Simulation deadline exhausted")
377
+ try:
378
+ timeout = timeout or self._timeout
379
+ if rpc_url:
380
+ result = self._caller.call(
381
+ rpc_url,
382
+ "eth_call",
383
+ (tx, "latest"),
384
+ timeout=timeout,
385
+ deadline=deadline,
386
+ block_identifier="latest",
387
+ )
388
+ else:
389
+ result = self.eth_call(tx, timeout=timeout, deadline=deadline)
390
+ return {"success": True, "result": result.hex() if isinstance(result, bytes) else result}
391
+ except Exception as exc: # noqa: BLE001
392
+ revert_reason = self._parse_revert_reason(exc)
393
+ if revert_reason:
394
+ raise SimulationReverted(revert_reason) from exc
395
+ raise SimulationNetworkError(str(exc)) from exc
396
+
397
+ def _parse_revert_reason(self, error: Exception) -> str | None:
398
+ error_str = str(error).lower()
399
+
400
+ error_code = None
401
+ if hasattr(error, "args"):
402
+ for arg in error.args:
403
+ if isinstance(arg, dict):
404
+ error_code = arg.get("code")
405
+ if error_code is None:
406
+ error_code = arg.get("error", {}).get("code")
407
+ if error_code is not None:
408
+ break
409
+
410
+ revert_error_codes = {-32000, -32015, 3}
411
+ if error_code in revert_error_codes:
412
+ return self._extract_revert_message(error)
413
+
414
+ revert_keywords = [
415
+ "execution reverted",
416
+ "revert",
417
+ "out of gas",
418
+ "insufficient funds",
419
+ "invalid opcode",
420
+ "stack underflow",
421
+ "stack overflow",
422
+ ]
423
+ if any(kw in error_str for kw in revert_keywords):
424
+ return self._extract_revert_message(error)
425
+
426
+ return None
427
+
428
+ def _extract_revert_message(self, error: Exception) -> str:
429
+ error_str = str(error)
430
+ if "execution reverted:" in error_str.lower():
431
+ idx = error_str.lower().find("execution reverted:")
432
+ return error_str[idx + len("execution reverted:"):].strip() or "execution reverted"
433
+
434
+ revert_data = self._extract_revert_data(error)
435
+ if revert_data:
436
+ decoded = self._decode_revert_data(revert_data)
437
+ if decoded:
438
+ return decoded
439
+
440
+ clean_msg = error_str
441
+ if len(clean_msg) > 200:
442
+ clean_msg = clean_msg[:200] + "..."
443
+ return clean_msg or "Transaction reverted"
444
+
445
+ def _extract_revert_data(self, error: Exception) -> str | None:
446
+ if hasattr(error, "args"):
447
+ for arg in error.args:
448
+ if isinstance(arg, dict):
449
+ data = arg.get("data")
450
+ if data is None:
451
+ data = arg.get("error", {}).get("data")
452
+ if isinstance(data, dict):
453
+ data = data.get("data") or data.get("result")
454
+ if isinstance(data, str) and data.startswith("0x"):
455
+ return data
456
+
457
+ error_str = str(error)
458
+ hex_match = re.search(r"0x[0-9a-fA-F]{8,}", error_str)
459
+ if hex_match:
460
+ return hex_match.group()
461
+
462
+ return None
463
+
464
+ def _decode_revert_data(self, data: str) -> str | None:
465
+ if len(data) < 10:
466
+ return None
467
+
468
+ selector = data[:10]
469
+
470
+ if selector == "0x08c379a0" and len(data) >= 138:
471
+ try:
472
+ from eth_abi import decode
473
+
474
+ decoded = decode(["string"], bytes.fromhex(data[10:]))
475
+ return decoded[0]
476
+ except Exception as exc: # noqa: BLE001
477
+ logger.debug(
478
+ "rpc.revert_decode_failed",
479
+ selector=selector,
480
+ error=str(exc)[:200],
481
+ )
482
+
483
+ if selector == "0x4e487b71" and len(data) >= 74:
484
+ try:
485
+ from eth_abi import decode
486
+
487
+ decoded = decode(["uint256"], bytes.fromhex(data[10:]))
488
+ panic_code = decoded[0]
489
+ panic_names = {
490
+ 0x00: "generic panic",
491
+ 0x01: "assertion failed",
492
+ 0x11: "arithmetic overflow",
493
+ 0x12: "division by zero",
494
+ 0x21: "invalid enum value",
495
+ 0x22: "storage encoding error",
496
+ 0x31: "pop on empty array",
497
+ 0x32: "array out of bounds",
498
+ 0x41: "memory allocation error",
499
+ 0x51: "zero function pointer",
500
+ }
501
+ return f"Panic({panic_code:#x}): {panic_names.get(panic_code, 'unknown')}"
502
+ except Exception as exc: # noqa: BLE001
503
+ logger.debug(
504
+ "rpc.revert_decode_failed",
505
+ selector=selector,
506
+ error=str(exc)[:200],
507
+ )
508
+
509
+ if len(data) > 74:
510
+ return f"Custom error {selector} ({len(data)//2 - 4} bytes)"
511
+ if len(data) > 10:
512
+ return f"Custom error {selector}"
513
+
514
+ return None
515
+
516
+ def get_health(self) -> dict[str, Any]:
517
+ total = len(self._pool.endpoints)
518
+ return {
519
+ "endpoints": list(self._pool.endpoints),
520
+ "healthy_endpoints": total,
521
+ "total_endpoints": total,
522
+ "all_unhealthy": total == 0,
523
+ }
524
+
525
+ def close(self) -> None:
526
+ return None
527
+
528
+
529
+ class BroadcastClient(ReadClient):
530
+ """Broadcast client with bound endpoint semantics."""
531
+
532
+ def __init__(self, *args: Any, bound: bool = True, **kwargs: Any) -> None:
533
+ super().__init__(*args, bound=bound, **kwargs)
534
+
535
+
536
+ def _default_request_id() -> str:
537
+ from uuid import uuid4
538
+
539
+ return uuid4().hex
brawny/_rpc/clients.py CHANGED
@@ -8,9 +8,11 @@ from __future__ import annotations
8
8
 
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ from brawny._rpc.client import ReadClient, BroadcastClient
12
+
11
13
  if TYPE_CHECKING:
12
14
  from brawny.config import Config
13
- from brawny._rpc.manager import RPCManager
15
+
14
16
 
15
17
 
16
18
  class RPCClients:
@@ -37,9 +39,9 @@ class RPCClients:
37
39
  config: Application configuration
38
40
  """
39
41
  self._config = config
40
- self._read_clients: dict[str, "RPCManager"] = {}
42
+ self._read_clients: dict[str, ReadClient] = {}
41
43
 
42
- def get_read_client(self, group_name: str) -> "RPCManager":
44
+ def get_read_client(self, group_name: str) -> ReadClient:
43
45
  """Get (cached) read client for a group.
44
46
 
45
47
  If the group's client hasn't been created yet, creates it.
@@ -49,39 +51,37 @@ class RPCClients:
49
51
  group_name: Name of the RPC group (e.g., "public", "private")
50
52
 
51
53
  Returns:
52
- RPCManager configured for the group's endpoints
54
+ ReadClient configured for the group's endpoints
53
55
 
54
56
  Raises:
55
57
  ValueError: If group not found in config.rpc_groups
56
58
  """
57
59
  if group_name not in self._read_clients:
58
- from brawny._rpc.manager import RPCManager
60
+ from brawny._rpc.retry_policy import fast_read_policy
59
61
 
60
62
  if group_name not in self._config.rpc_groups:
61
63
  raise ValueError(f"RPC group '{group_name}' not found")
62
64
 
63
65
  group = self._config.rpc_groups[group_name]
64
- self._read_clients[group_name] = RPCManager(
66
+ self._read_clients[group_name] = ReadClient(
65
67
  endpoints=group.endpoints,
66
68
  timeout_seconds=self._config.rpc_timeout_seconds,
67
69
  max_retries=self._config.rpc_max_retries,
68
70
  retry_backoff_base=self._config.rpc_retry_backoff_base,
69
- circuit_breaker_seconds=self._config.rpc_circuit_breaker_seconds,
70
- rate_limit_per_second=self._config.rpc_rate_limit_per_second,
71
- rate_limit_burst=self._config.rpc_rate_limit_burst,
71
+ retry_policy=fast_read_policy(self._config),
72
72
  chain_id=self._config.chain_id,
73
73
  log_init=False, # Daemon already logged main RPC init
74
74
  )
75
75
 
76
76
  return self._read_clients[group_name]
77
77
 
78
- def get_default_client(self) -> "RPCManager":
78
+ def get_default_client(self) -> ReadClient:
79
79
  """Get the default read client.
80
80
 
81
81
  Uses config.rpc_default_group if set, otherwise requires a single rpc_group.
82
82
 
83
83
  Returns:
84
- RPCManager for the default group
84
+ ReadClient for the default group
85
85
 
86
86
  Raises:
87
87
  ValueError: If default group cannot be resolved
brawny/_rpc/context.py CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  from contextvars import ContextVar, Token
18
18
 
19
19
  _rpc_job_ctx: ContextVar[str | None] = ContextVar("rpc_job_ctx", default=None)
20
+ _rpc_intent_budget_ctx: ContextVar[str | None] = ContextVar("rpc_intent_budget_ctx", default=None)
20
21
 
21
22
 
22
23
  def set_job_context(job_id: str | None) -> Token:
@@ -47,3 +48,25 @@ def get_job_context() -> str | None:
47
48
  Job ID if set, None otherwise
48
49
  """
49
50
  return _rpc_job_ctx.get()
51
+
52
+
53
+ def set_intent_budget_context(budget_key: str | None) -> Token:
54
+ """Set the current intent budget key for retry policies.
55
+
56
+ Args:
57
+ budget_key: Budget key string (chain_id:signer:intent_id), or None to clear
58
+
59
+ Returns:
60
+ Token for resetting context via reset_intent_budget_context()
61
+ """
62
+ return _rpc_intent_budget_ctx.set(budget_key)
63
+
64
+
65
+ def reset_intent_budget_context(token: Token) -> None:
66
+ """Reset intent budget context to previous value."""
67
+ _rpc_intent_budget_ctx.reset(token)
68
+
69
+
70
+ def get_intent_budget_context() -> str | None:
71
+ """Get the current intent budget key."""
72
+ return _rpc_intent_budget_ctx.get()