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/_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
|
-
|
|
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,
|
|
42
|
+
self._read_clients: dict[str, ReadClient] = {}
|
|
41
43
|
|
|
42
|
-
def get_read_client(self, group_name: str) ->
|
|
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
|
-
|
|
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.
|
|
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] =
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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()
|