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.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. 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