brawny 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
brawny/_rpc/errors.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""RPC error types and classification for brawny.
|
|
2
|
+
|
|
3
|
+
Error classification per SPEC:
|
|
4
|
+
- Retryable: Network/RPC issues, should retry with backoff
|
|
5
|
+
- Fatal TX: Transaction issues, do not retry with same params
|
|
6
|
+
- Recoverable TX: May succeed with different params (e.g., bumped gas)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from brawny.model.errors import BrawnyError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RPCError(BrawnyError):
|
|
15
|
+
"""Base RPC error."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
code: str | None = None,
|
|
21
|
+
endpoint: str | None = None,
|
|
22
|
+
method: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.code = code
|
|
26
|
+
self.endpoint = endpoint
|
|
27
|
+
self.method = method
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RPCRetryableError(RPCError):
|
|
31
|
+
"""RPC error that should be retried.
|
|
32
|
+
|
|
33
|
+
These are network/infrastructure issues that may resolve
|
|
34
|
+
on retry or with a different endpoint.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RPCFatalError(RPCError):
|
|
41
|
+
"""Fatal RPC error that should not be retried.
|
|
42
|
+
|
|
43
|
+
These are transaction-level errors that won't be fixed
|
|
44
|
+
by retrying with the same parameters.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RPCRecoverableError(RPCError):
|
|
51
|
+
"""RPC error that may succeed with different parameters.
|
|
52
|
+
|
|
53
|
+
Examples: underpriced transactions that need gas bump.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RPCPoolExhaustedError(RPCError):
|
|
60
|
+
"""All endpoints in a pool failed (internal, group-agnostic).
|
|
61
|
+
|
|
62
|
+
This is raised by RPCManager when all endpoints fail during an operation.
|
|
63
|
+
It does not include group context - the caller (broadcast layer) wraps
|
|
64
|
+
this into RPCGroupUnavailableError with group context.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
message: str,
|
|
70
|
+
endpoints: list[str],
|
|
71
|
+
last_error: Exception | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(message)
|
|
74
|
+
self.endpoints = endpoints
|
|
75
|
+
self.last_error = last_error
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RPCGroupUnavailableError(RPCError):
|
|
79
|
+
"""All endpoints in a broadcast group are unavailable (user-facing).
|
|
80
|
+
|
|
81
|
+
This is the user-facing error that includes group context. It wraps
|
|
82
|
+
RPCPoolExhaustedError with the group name for logging and diagnostics.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
message: str,
|
|
88
|
+
group_name: str | None,
|
|
89
|
+
endpoints: list[str],
|
|
90
|
+
last_error: Exception | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
self.group_name = group_name
|
|
94
|
+
self.endpoints = endpoints
|
|
95
|
+
self.last_error = last_error
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ============================================================================
|
|
99
|
+
# Retryable errors (network/infrastructure issues)
|
|
100
|
+
# ============================================================================
|
|
101
|
+
RETRYABLE_ERROR_CODES = frozenset({
|
|
102
|
+
"timeout",
|
|
103
|
+
"connection_refused",
|
|
104
|
+
"connection_reset",
|
|
105
|
+
"connection_error",
|
|
106
|
+
"rate_limited", # HTTP 429
|
|
107
|
+
"bad_gateway", # HTTP 502
|
|
108
|
+
"service_unavailable", # HTTP 503
|
|
109
|
+
"gateway_timeout", # HTTP 504
|
|
110
|
+
"internal_error", # JSON-RPC -32603
|
|
111
|
+
"server_error", # JSON-RPC -32000 to -32099
|
|
112
|
+
"request_timeout",
|
|
113
|
+
"network_error",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
# HTTP status codes that indicate retryable errors
|
|
117
|
+
RETRYABLE_HTTP_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
118
|
+
|
|
119
|
+
# JSON-RPC error codes that are retryable
|
|
120
|
+
# -32603: Internal error
|
|
121
|
+
# -32000 to -32099: Server error (implementation defined)
|
|
122
|
+
RETRYABLE_RPC_CODES = frozenset({-32603} | set(range(-32099, -32000 + 1)))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ============================================================================
|
|
126
|
+
# Fatal transaction errors (do not retry with same params)
|
|
127
|
+
# ============================================================================
|
|
128
|
+
FATAL_TX_ERROR_CODES = frozenset({
|
|
129
|
+
"nonce_too_low", # Already used nonce
|
|
130
|
+
"insufficient_funds", # Need more ETH
|
|
131
|
+
"gas_limit_exceeded", # TX exceeds block gas limit
|
|
132
|
+
"execution_reverted", # Contract rejected
|
|
133
|
+
"invalid_sender", # Bad signature
|
|
134
|
+
"invalid_nonce", # Nonce issues
|
|
135
|
+
"intrinsic_gas_too_low", # Gas below intrinsic
|
|
136
|
+
"exceeds_block_gas_limit",
|
|
137
|
+
"account_balance_too_low",
|
|
138
|
+
"tx_type_not_supported",
|
|
139
|
+
"max_fee_too_low",
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
# Substrings in error messages that indicate fatal errors
|
|
143
|
+
FATAL_TX_SUBSTRINGS = frozenset({
|
|
144
|
+
"nonce too low",
|
|
145
|
+
"insufficient funds",
|
|
146
|
+
"execution reverted",
|
|
147
|
+
"invalid sender",
|
|
148
|
+
"gas limit exceeded",
|
|
149
|
+
"intrinsic gas too low",
|
|
150
|
+
"already known", # Transaction already in mempool
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ============================================================================
|
|
155
|
+
# Recoverable transaction errors (may succeed with different params)
|
|
156
|
+
# ============================================================================
|
|
157
|
+
RECOVERABLE_TX_ERROR_CODES = frozenset({
|
|
158
|
+
"replacement_underpriced", # Retry with bumped gas
|
|
159
|
+
"transaction_underpriced", # Base fee too low
|
|
160
|
+
"underpriced",
|
|
161
|
+
"max_priority_fee_too_low",
|
|
162
|
+
"max_fee_per_gas_too_low",
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
# Substrings in error messages that indicate recoverable errors
|
|
166
|
+
RECOVERABLE_TX_SUBSTRINGS = frozenset({
|
|
167
|
+
"replacement transaction underpriced",
|
|
168
|
+
"transaction underpriced",
|
|
169
|
+
"max priority fee",
|
|
170
|
+
"max fee per gas",
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def classify_error(
|
|
175
|
+
error: Exception,
|
|
176
|
+
http_status: int | None = None,
|
|
177
|
+
rpc_code: int | None = None,
|
|
178
|
+
) -> type[RPCError]:
|
|
179
|
+
"""Classify an error into RPCRetryableError, RPCFatalError, or RPCRecoverableError.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
error: The exception to classify
|
|
183
|
+
http_status: HTTP status code if available
|
|
184
|
+
rpc_code: JSON-RPC error code if available
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The appropriate error class
|
|
188
|
+
"""
|
|
189
|
+
error_msg = str(error).lower()
|
|
190
|
+
|
|
191
|
+
# Check HTTP status first
|
|
192
|
+
if http_status and http_status in RETRYABLE_HTTP_STATUS:
|
|
193
|
+
return RPCRetryableError
|
|
194
|
+
|
|
195
|
+
# Check JSON-RPC error code
|
|
196
|
+
if rpc_code and rpc_code in RETRYABLE_RPC_CODES:
|
|
197
|
+
return RPCRetryableError
|
|
198
|
+
|
|
199
|
+
# Check for recoverable TX errors (check before fatal)
|
|
200
|
+
for substring in RECOVERABLE_TX_SUBSTRINGS:
|
|
201
|
+
if substring in error_msg:
|
|
202
|
+
return RPCRecoverableError
|
|
203
|
+
|
|
204
|
+
# Check for fatal TX errors
|
|
205
|
+
for substring in FATAL_TX_SUBSTRINGS:
|
|
206
|
+
if substring in error_msg:
|
|
207
|
+
return RPCFatalError
|
|
208
|
+
|
|
209
|
+
# Check common error patterns
|
|
210
|
+
if "timeout" in error_msg or "timed out" in error_msg:
|
|
211
|
+
return RPCRetryableError
|
|
212
|
+
if "connection" in error_msg:
|
|
213
|
+
return RPCRetryableError
|
|
214
|
+
if "rate limit" in error_msg:
|
|
215
|
+
return RPCRetryableError
|
|
216
|
+
if "reverted" in error_msg:
|
|
217
|
+
return RPCFatalError
|
|
218
|
+
if "nonce" in error_msg and ("low" in error_msg or "invalid" in error_msg):
|
|
219
|
+
return RPCFatalError
|
|
220
|
+
if "insufficient" in error_msg:
|
|
221
|
+
return RPCFatalError
|
|
222
|
+
|
|
223
|
+
# Default to retryable for unknown errors
|
|
224
|
+
return RPCRetryableError
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def normalize_error_code(error: Exception) -> str:
|
|
228
|
+
"""Extract a normalized error code from an exception.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
error: The exception to normalize
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Normalized error code string
|
|
235
|
+
"""
|
|
236
|
+
error_msg = str(error).lower()
|
|
237
|
+
|
|
238
|
+
# Check known patterns
|
|
239
|
+
for code in FATAL_TX_ERROR_CODES:
|
|
240
|
+
if code.replace("_", " ") in error_msg or code.replace("_", "") in error_msg:
|
|
241
|
+
return code
|
|
242
|
+
|
|
243
|
+
for code in RECOVERABLE_TX_ERROR_CODES:
|
|
244
|
+
if code.replace("_", " ") in error_msg or code.replace("_", "") in error_msg:
|
|
245
|
+
return code
|
|
246
|
+
|
|
247
|
+
for code in RETRYABLE_ERROR_CODES:
|
|
248
|
+
if code.replace("_", " ") in error_msg or code.replace("_", "") in error_msg:
|
|
249
|
+
return code
|
|
250
|
+
|
|
251
|
+
# Generic fallback
|
|
252
|
+
return "unknown_error"
|
brawny/_rpc/gas.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Gas quote system with block-aware caching.
|
|
2
|
+
|
|
3
|
+
Cache semantics (OE6):
|
|
4
|
+
- Cache is keyed by (block_number, block_hash) to handle same-height reorgs
|
|
5
|
+
- Cache hit: if block (number, hash) is identical, return cached base_fee
|
|
6
|
+
- One RPC call per invocation (get_block returns number, hash, baseFee)
|
|
7
|
+
|
|
8
|
+
Why no TTL? We fetch the latest block on every call to get current (number, hash).
|
|
9
|
+
If they match our cache, the base_fee hasn't changed. TTL would only add complexity
|
|
10
|
+
without reducing RPC calls.
|
|
11
|
+
|
|
12
|
+
Reorg handling: If a reorg replaces block N with different content, the block hash
|
|
13
|
+
changes. Our cache key includes hash, so we'll miss and refetch.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import time
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from brawny._rpc.manager import RPCManager
|
|
26
|
+
|
|
27
|
+
# Bounded executor for async wrappers (prevents thread starvation)
|
|
28
|
+
_GAS_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="gas_rpc")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class GasQuote:
|
|
33
|
+
"""EIP-1559 gas quote with block context for cache validation."""
|
|
34
|
+
|
|
35
|
+
base_fee: int
|
|
36
|
+
block_number: int
|
|
37
|
+
block_hash: str
|
|
38
|
+
timestamp: float
|
|
39
|
+
|
|
40
|
+
def matches_block(self, block_number: int, block_hash: str) -> bool:
|
|
41
|
+
"""Check if this quote matches the given block (number AND hash)."""
|
|
42
|
+
return self.block_number == block_number and self.block_hash == block_hash
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GasQuoteCache:
|
|
46
|
+
"""Gas quote cache keyed by (block_number, block_hash).
|
|
47
|
+
|
|
48
|
+
Cache hit requires: same block (number AND hash).
|
|
49
|
+
No TTL needed since we fetch latest block each call.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, rpc: "RPCManager", ttl_seconds: int = 15) -> None:
|
|
53
|
+
"""Initialize gas cache.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
rpc: RPC manager for fetching blocks
|
|
57
|
+
ttl_seconds: Ignored (kept for backwards compatibility).
|
|
58
|
+
Cache is now keyed by block, not TTL.
|
|
59
|
+
"""
|
|
60
|
+
self._rpc = rpc
|
|
61
|
+
self._cache: GasQuote | None = None
|
|
62
|
+
self._lock = asyncio.Lock()
|
|
63
|
+
|
|
64
|
+
async def get_quote(self) -> GasQuote:
|
|
65
|
+
"""Get gas quote (async).
|
|
66
|
+
|
|
67
|
+
Cache hit: same block (number AND hash) -> return cached.
|
|
68
|
+
One RPC call per invocation regardless of cache hit/miss.
|
|
69
|
+
"""
|
|
70
|
+
async with self._lock:
|
|
71
|
+
return await self._fetch_quote()
|
|
72
|
+
|
|
73
|
+
async def _fetch_quote(self) -> GasQuote:
|
|
74
|
+
"""Fetch quote from RPC, using cache if block matches."""
|
|
75
|
+
loop = asyncio.get_running_loop()
|
|
76
|
+
|
|
77
|
+
# Run sync RPC call in bounded executor with timeout
|
|
78
|
+
block = await asyncio.wait_for(
|
|
79
|
+
loop.run_in_executor(_GAS_EXECUTOR, lambda: self._rpc.get_block("latest")),
|
|
80
|
+
timeout=10.0,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Extract block identifiers
|
|
84
|
+
block_number = block.get("number", 0)
|
|
85
|
+
if isinstance(block_number, str):
|
|
86
|
+
block_number = int(block_number, 16)
|
|
87
|
+
block_hash = block.get("hash", "")
|
|
88
|
+
if hasattr(block_hash, "hex"):
|
|
89
|
+
block_hash = block_hash.hex()
|
|
90
|
+
elif not isinstance(block_hash, str):
|
|
91
|
+
block_hash = str(block_hash)
|
|
92
|
+
|
|
93
|
+
# Cache hit: same block (number AND hash)
|
|
94
|
+
if self._cache is not None and self._cache.matches_block(block_number, block_hash):
|
|
95
|
+
return self._cache
|
|
96
|
+
|
|
97
|
+
# Cache miss: extract base_fee from already-fetched block
|
|
98
|
+
base_fee = block.get("baseFeePerGas", 0)
|
|
99
|
+
if isinstance(base_fee, str):
|
|
100
|
+
base_fee = int(base_fee, 16)
|
|
101
|
+
else:
|
|
102
|
+
base_fee = int(base_fee) if base_fee else 0
|
|
103
|
+
|
|
104
|
+
# Validate base_fee (0 = missing/pre-EIP-1559 chain = invalid)
|
|
105
|
+
if base_fee == 0:
|
|
106
|
+
raise ValueError("baseFeePerGas is 0 or missing (non-EIP-1559 chain?)")
|
|
107
|
+
|
|
108
|
+
quote = GasQuote(
|
|
109
|
+
base_fee=base_fee,
|
|
110
|
+
block_number=block_number,
|
|
111
|
+
block_hash=block_hash,
|
|
112
|
+
timestamp=time.time(),
|
|
113
|
+
)
|
|
114
|
+
self._cache = quote
|
|
115
|
+
return quote
|
|
116
|
+
|
|
117
|
+
def get_quote_sync(self) -> GasQuote | None:
|
|
118
|
+
"""Get cached quote if available (non-blocking, for executor).
|
|
119
|
+
|
|
120
|
+
Returns cached quote without checking block freshness.
|
|
121
|
+
Caller should be aware this may be from a previous block.
|
|
122
|
+
"""
|
|
123
|
+
if self._cache is None:
|
|
124
|
+
self._cache = self._fetch_quote_sync()
|
|
125
|
+
return self._cache
|
|
126
|
+
|
|
127
|
+
def _fetch_quote_sync(self) -> GasQuote:
|
|
128
|
+
"""Fetch quote synchronously from RPC."""
|
|
129
|
+
block = self._rpc.get_block("latest")
|
|
130
|
+
|
|
131
|
+
block_number = block.get("number", 0)
|
|
132
|
+
if isinstance(block_number, str):
|
|
133
|
+
block_number = int(block_number, 16)
|
|
134
|
+
block_hash = block.get("hash", "")
|
|
135
|
+
if hasattr(block_hash, "hex"):
|
|
136
|
+
block_hash = block_hash.hex()
|
|
137
|
+
elif not isinstance(block_hash, str):
|
|
138
|
+
block_hash = str(block_hash)
|
|
139
|
+
|
|
140
|
+
base_fee = block.get("baseFeePerGas", 0)
|
|
141
|
+
if isinstance(base_fee, str):
|
|
142
|
+
base_fee = int(base_fee, 16)
|
|
143
|
+
else:
|
|
144
|
+
base_fee = int(base_fee) if base_fee else 0
|
|
145
|
+
|
|
146
|
+
if base_fee == 0:
|
|
147
|
+
raise ValueError("baseFeePerGas is 0 or missing (non-EIP-1559 chain?)")
|
|
148
|
+
|
|
149
|
+
return GasQuote(
|
|
150
|
+
base_fee=base_fee,
|
|
151
|
+
block_number=block_number,
|
|
152
|
+
block_hash=block_hash,
|
|
153
|
+
timestamp=time.time(),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def invalidate(self) -> None:
|
|
157
|
+
"""Force refresh on next call."""
|
|
158
|
+
self._cache = None
|