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/model/contexts.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Phase-specific contexts for the job lifecycle.
|
|
2
|
+
|
|
3
|
+
Each phase gets only what it needs:
|
|
4
|
+
- CheckContext: Read chain state, return Trigger. KV is read+write.
|
|
5
|
+
- BuildContext: Produces TxSpec. Has trigger + signer. KV is read-only.
|
|
6
|
+
- AlertContext: Receives immutable snapshots. KV is read-only.
|
|
7
|
+
|
|
8
|
+
Contract access is explicit and block-aware:
|
|
9
|
+
- at_block(name, addr, block): Pinned reads for check()
|
|
10
|
+
- at(name, addr): Latest reads for build/alerts
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
17
|
+
|
|
18
|
+
from brawny.model.events import DecodedEvent
|
|
19
|
+
from brawny.model.errors import FailureType
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from brawny.jobs.kv import KVReader, KVStore
|
|
23
|
+
from brawny.model.types import Trigger, TxIntent, TxAttempt
|
|
24
|
+
from brawny.jobs.base import TxInfo, TxReceipt
|
|
25
|
+
from brawny.alerts.send import AlertConfig
|
|
26
|
+
from brawny.config.models import TelegramConfig
|
|
27
|
+
from brawny.telegram import TelegramBot
|
|
28
|
+
import structlog
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# Contract Factory Protocol
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContractHandle(Protocol):
|
|
37
|
+
"""Protocol for contract handles. Actual implementation in alerts/contracts.py."""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def address(self) -> str:
|
|
41
|
+
"""Contract address (checksummed)."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def _call_with_calldata(self, calldata: str, abi: Any) -> Any:
|
|
45
|
+
"""Execute eth_call with pre-encoded calldata."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ContractFactory(Protocol):
|
|
50
|
+
"""Factory for creating contract handles.
|
|
51
|
+
|
|
52
|
+
Block-aware contract access:
|
|
53
|
+
- at(): Get handle reading at 'latest'. Use in build/alerts.
|
|
54
|
+
- at_block(): Get handle pinned to specific block. Use in check().
|
|
55
|
+
- with_abi(): Get handle with explicit ABI.
|
|
56
|
+
|
|
57
|
+
Factory stays dumb:
|
|
58
|
+
- Does not silently switch endpoints/groups
|
|
59
|
+
- Does not mutate global caches
|
|
60
|
+
- Is deterministic under a given rpc + abi_resolver
|
|
61
|
+
- Factory binds handles; resolver owns policy
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def at(self, name: str, address: str) -> ContractHandle:
|
|
65
|
+
"""Get contract handle, reads at 'latest'. Use in build/alerts."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def at_block(self, name: str, address: str, block: int) -> ContractHandle:
|
|
69
|
+
"""Get contract handle pinned to specific block. Use in check()."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def with_abi(self, address: str, abi: list[Any]) -> ContractHandle:
|
|
73
|
+
"""Get contract handle with explicit ABI."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# =============================================================================
|
|
78
|
+
# Block Context (Immutable Snapshot)
|
|
79
|
+
# =============================================================================
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class BlockContext:
|
|
84
|
+
"""Immutable chain state snapshot at check() time.
|
|
85
|
+
|
|
86
|
+
Contains only chain state metadata. Jobs can still make RPC calls,
|
|
87
|
+
but check() reads should be block-pinned using ctx.block.number.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
number: int
|
|
91
|
+
timestamp: int
|
|
92
|
+
hash: str
|
|
93
|
+
base_fee: int
|
|
94
|
+
chain_id: int
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Check Context
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class CheckContext:
|
|
104
|
+
"""Context available during check(). Read chain state, return Trigger.
|
|
105
|
+
|
|
106
|
+
Semantic rules:
|
|
107
|
+
- check() may read chain state + mutate kv
|
|
108
|
+
- Returns Trigger | None
|
|
109
|
+
- Use ctx.contracts.at_block(name, addr, ctx.block.number) for block-pinned reads
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
block: BlockContext
|
|
113
|
+
kv: KVStore # Read+write allowed
|
|
114
|
+
job_id: str
|
|
115
|
+
rpc: Any # RPCManager or similar
|
|
116
|
+
logger: "structlog.stdlib.BoundLogger"
|
|
117
|
+
contracts: ContractFactory
|
|
118
|
+
_db: Any = None # Internal: for log()
|
|
119
|
+
|
|
120
|
+
def log(self, level: str = "info", **fields: Any) -> None:
|
|
121
|
+
"""Record structured snapshot. Best-effort, never interrupts."""
|
|
122
|
+
if self._db is None:
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
from brawny.db.ops import logs as log_ops
|
|
126
|
+
log_ops.insert_log(
|
|
127
|
+
self._db, self.block.chain_id, self.job_id,
|
|
128
|
+
self.block.number, level, fields
|
|
129
|
+
)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
self.logger.warning("log_failed", error=str(e))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# =============================================================================
|
|
135
|
+
# Build Context
|
|
136
|
+
# =============================================================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class BuildContext:
|
|
141
|
+
"""Context available during build_tx(). Produces transaction spec.
|
|
142
|
+
|
|
143
|
+
Semantic rules:
|
|
144
|
+
- build_tx() produces a TxSpec
|
|
145
|
+
- Has trigger + signer_address
|
|
146
|
+
- KV is read-only
|
|
147
|
+
- ctx.contracts.at(name, addr) defaults to 'latest', fine for build
|
|
148
|
+
- Safety-critical predicates should be computed in check() and encoded in Trigger.data
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
block: BlockContext
|
|
152
|
+
trigger: "Trigger"
|
|
153
|
+
job_id: str
|
|
154
|
+
signer_address: str # Signer belongs here, not on RPC
|
|
155
|
+
rpc: Any # RPCManager or similar
|
|
156
|
+
logger: "structlog.stdlib.BoundLogger"
|
|
157
|
+
contracts: ContractFactory
|
|
158
|
+
kv: KVReader # Read-only
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# =============================================================================
|
|
162
|
+
# Alert Context
|
|
163
|
+
# =============================================================================
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class AlertContext:
|
|
168
|
+
"""Context available during alert hooks. Immutable snapshots only.
|
|
169
|
+
|
|
170
|
+
Semantic rules:
|
|
171
|
+
- Alert hooks receive immutable snapshots
|
|
172
|
+
- KV is read-only
|
|
173
|
+
- events is raw list[DecodedEvent], not an accessor framework
|
|
174
|
+
- chain_id comes from block.chain_id (no duplication)
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
block: BlockContext
|
|
178
|
+
trigger: "Trigger"
|
|
179
|
+
tx: "TxInfo | None"
|
|
180
|
+
receipt: "TxReceipt | None"
|
|
181
|
+
events: list[DecodedEvent] | None # Raw list, not a framework
|
|
182
|
+
failure_type: FailureType | None
|
|
183
|
+
error_info: Any | None # ErrorInfo for failure context
|
|
184
|
+
logger: "structlog.stdlib.BoundLogger"
|
|
185
|
+
contracts: ContractFactory
|
|
186
|
+
kv: KVReader # Read-only
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def has_receipt(self) -> bool:
|
|
190
|
+
"""Check if receipt is available."""
|
|
191
|
+
return self.receipt is not None
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def has_error(self) -> bool:
|
|
195
|
+
"""Check if this is a failure context."""
|
|
196
|
+
return self.failure_type is not None
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def error_message(self) -> str:
|
|
200
|
+
"""Convenience: error message or 'unknown'."""
|
|
201
|
+
if self.error_info is not None and hasattr(self.error_info, "message"):
|
|
202
|
+
return self.error_info.message
|
|
203
|
+
return "unknown"
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def chain_id(self) -> int:
|
|
207
|
+
"""Chain ID from block context."""
|
|
208
|
+
return self.block.chain_id
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# =============================================================================
|
|
212
|
+
# Hook Contexts (New Simplified API)
|
|
213
|
+
# =============================================================================
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class TriggerContext:
|
|
218
|
+
"""Passed to on_trigger. Trigger still exists here.
|
|
219
|
+
|
|
220
|
+
Use for:
|
|
221
|
+
- Monitor-only jobs (tx_required=False) - your only hook
|
|
222
|
+
- Pre-transaction alerts/logging
|
|
223
|
+
- KV updates before intent creation
|
|
224
|
+
|
|
225
|
+
Note: No intent exists yet. After this hook, trigger is gone -
|
|
226
|
+
only intent.metadata persists.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
trigger: "Trigger"
|
|
230
|
+
block: BlockContext
|
|
231
|
+
kv: "KVStore" # Read+write (pre-intent)
|
|
232
|
+
logger: "structlog.stdlib.BoundLogger"
|
|
233
|
+
# Alert routing
|
|
234
|
+
job_id: str
|
|
235
|
+
job_name: str
|
|
236
|
+
chain_id: int
|
|
237
|
+
alert_config: "AlertConfig"
|
|
238
|
+
telegram_config: "TelegramConfig" # Always exists (Config.telegram has default factory)
|
|
239
|
+
# Optional telegram fields
|
|
240
|
+
telegram_bot: "TelegramBot | None" = None
|
|
241
|
+
job_alert_to: list[str] | None = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class SuccessContext:
|
|
246
|
+
"""Passed to on_success. No trigger - use intent.metadata.
|
|
247
|
+
|
|
248
|
+
ctx.intent.metadata["reason"] = original trigger.reason
|
|
249
|
+
ctx.intent.metadata[...] = your custom data from build_tx()
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
intent: "TxIntent"
|
|
253
|
+
receipt: "TxReceipt"
|
|
254
|
+
events: list[DecodedEvent] | None
|
|
255
|
+
block: BlockContext
|
|
256
|
+
kv: "KVReader" # Read-only
|
|
257
|
+
logger: "structlog.stdlib.BoundLogger"
|
|
258
|
+
# Alert routing
|
|
259
|
+
job_id: str
|
|
260
|
+
job_name: str
|
|
261
|
+
chain_id: int
|
|
262
|
+
alert_config: "AlertConfig"
|
|
263
|
+
telegram_config: "TelegramConfig" # Always exists (Config.telegram has default factory)
|
|
264
|
+
# Optional telegram fields
|
|
265
|
+
telegram_bot: "TelegramBot | None" = None
|
|
266
|
+
job_alert_to: list[str] | None = None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(frozen=True)
|
|
270
|
+
class FailureContext:
|
|
271
|
+
"""Passed to on_failure. Intent may be None for pre-intent failures.
|
|
272
|
+
|
|
273
|
+
Pre-intent failures include:
|
|
274
|
+
- check() exception
|
|
275
|
+
- build_tx() exception
|
|
276
|
+
- intent creation failure
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
intent: "TxIntent | None" # None for pre-intent failures
|
|
280
|
+
attempt: "TxAttempt | None" # intent None for pre-intent; attempt may be None even post-intent
|
|
281
|
+
error: Exception
|
|
282
|
+
failure_type: FailureType
|
|
283
|
+
failure_stage: FailureStage | None
|
|
284
|
+
block: BlockContext
|
|
285
|
+
kv: "KVReader" # Read-only (no side effects during failure handling)
|
|
286
|
+
logger: "structlog.stdlib.BoundLogger"
|
|
287
|
+
# Alert routing
|
|
288
|
+
job_id: str
|
|
289
|
+
job_name: str
|
|
290
|
+
chain_id: int
|
|
291
|
+
alert_config: "AlertConfig"
|
|
292
|
+
telegram_config: "TelegramConfig" # Always exists (Config.telegram has default factory)
|
|
293
|
+
# Optional telegram fields
|
|
294
|
+
telegram_bot: "TelegramBot | None" = None
|
|
295
|
+
job_alert_to: list[str] | None = None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# =============================================================================
|
|
299
|
+
# Context Size Caps (Prevent Re-Growth)
|
|
300
|
+
# =============================================================================
|
|
301
|
+
|
|
302
|
+
# These are validated in tests to prevent gradual re-growth into god object:
|
|
303
|
+
# - BlockContext: <= 5 fields
|
|
304
|
+
# - CheckContext: <= 7 fields
|
|
305
|
+
# - BuildContext: <= 9 fields
|
|
306
|
+
# - AlertContext: <= 10 fields
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
__all__ = [
|
|
310
|
+
"BlockContext",
|
|
311
|
+
"CheckContext",
|
|
312
|
+
"BuildContext",
|
|
313
|
+
"AlertContext",
|
|
314
|
+
"TriggerContext",
|
|
315
|
+
"SuccessContext",
|
|
316
|
+
"FailureContext",
|
|
317
|
+
"ContractFactory",
|
|
318
|
+
"ContractHandle",
|
|
319
|
+
]
|
brawny/model/enums.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Enumerations for brawny statuses and states."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IntentStatus(str, Enum):
|
|
7
|
+
"""Transaction intent status."""
|
|
8
|
+
|
|
9
|
+
CREATED = "created"
|
|
10
|
+
CLAIMED = "claimed"
|
|
11
|
+
SENDING = "sending"
|
|
12
|
+
PENDING = "pending"
|
|
13
|
+
CONFIRMED = "confirmed"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
ABANDONED = "abandoned"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AttemptStatus(str, Enum):
|
|
19
|
+
"""Transaction attempt status."""
|
|
20
|
+
|
|
21
|
+
SIGNED = "signed"
|
|
22
|
+
BROADCAST = "broadcast"
|
|
23
|
+
PENDING = "pending"
|
|
24
|
+
CONFIRMED = "confirmed"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
REPLACED = "replaced"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NonceStatus(str, Enum):
|
|
30
|
+
"""Nonce reservation status."""
|
|
31
|
+
|
|
32
|
+
RESERVED = "reserved"
|
|
33
|
+
IN_FLIGHT = "in_flight"
|
|
34
|
+
RELEASED = "released"
|
|
35
|
+
ORPHANED = "orphaned"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TxStatus(str, Enum):
|
|
39
|
+
"""Transaction lifecycle status. 4 states only.
|
|
40
|
+
|
|
41
|
+
IMPORTANT: Do not add new statuses. This enum is intentionally minimal.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
CREATED = "created" # Exists, not yet broadcast
|
|
45
|
+
BROADCAST = "broadcast" # Has current_tx_hash, awaiting confirmation
|
|
46
|
+
CONFIRMED = "confirmed" # Terminal: receipt status=1
|
|
47
|
+
FAILED = "failed" # Terminal: permanent failure
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ABISource(str, Enum):
|
|
51
|
+
"""Source of ABI data."""
|
|
52
|
+
|
|
53
|
+
ETHERSCAN = "etherscan"
|
|
54
|
+
SOURCIFY = "sourcify"
|
|
55
|
+
MANUAL = "manual"
|
|
56
|
+
PROXY_IMPLEMENTATION = "proxy_implementation"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KeystoreType(str, Enum):
|
|
60
|
+
"""Keystore type for private key management."""
|
|
61
|
+
|
|
62
|
+
ENV = "env"
|
|
63
|
+
FILE = "file"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LogFormat(str, Enum):
|
|
67
|
+
"""Log output format."""
|
|
68
|
+
|
|
69
|
+
JSON = "json"
|
|
70
|
+
TEXT = "text"
|
brawny/model/errors.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Error types for brawny."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HookType(Enum):
|
|
8
|
+
"""Alert hook types."""
|
|
9
|
+
|
|
10
|
+
TRIGGERED = "triggered"
|
|
11
|
+
CONFIRMED = "confirmed"
|
|
12
|
+
FAILED = "failed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TriggerReason:
|
|
16
|
+
"""Constants for synthetic trigger reasons (not from job.check())."""
|
|
17
|
+
|
|
18
|
+
MISSING = "missing_trigger"
|
|
19
|
+
UNKNOWN_VERSION = "unknown_version"
|
|
20
|
+
INVALID_FORMAT = "invalid_format"
|
|
21
|
+
# CHECK_EXCEPTION and BUILD_TX_EXCEPTION use FailureType.*.value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ErrorInfo:
|
|
26
|
+
"""Structured error for alert context. JSON-safe."""
|
|
27
|
+
|
|
28
|
+
error_type: str # e.g., "SimulationReverted", "BroadcastFailed"
|
|
29
|
+
message: str # Truncated, sanitized
|
|
30
|
+
code: str | None = None # RPC error code if applicable
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_exception(cls, e: Exception, max_len: int = 200) -> "ErrorInfo":
|
|
34
|
+
"""Create ErrorInfo from an exception."""
|
|
35
|
+
code = getattr(e, "code", None)
|
|
36
|
+
return cls(
|
|
37
|
+
error_type=type(e).__name__,
|
|
38
|
+
message=str(e)[:max_len],
|
|
39
|
+
code=str(code) if code is not None else None,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FailureStage(Enum):
|
|
44
|
+
"""When in the lifecycle did the failure occur."""
|
|
45
|
+
|
|
46
|
+
PRE_BROADCAST = "pre_broadcast" # Never made it to chain
|
|
47
|
+
BROADCAST = "broadcast" # Failed during broadcast
|
|
48
|
+
POST_BROADCAST = "post_broadcast" # Failed after broadcast (on-chain)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FailureType(Enum):
|
|
52
|
+
"""What specifically failed."""
|
|
53
|
+
|
|
54
|
+
# Permanent (no retry will help)
|
|
55
|
+
SIMULATION_REVERTED = "simulation_reverted" # TX would revert
|
|
56
|
+
TX_REVERTED = "tx_reverted" # On-chain revert
|
|
57
|
+
DEADLINE_EXPIRED = "deadline_expired" # Intent too old
|
|
58
|
+
|
|
59
|
+
# Transient (might resolve on retry)
|
|
60
|
+
SIMULATION_NETWORK_ERROR = "simulation_network_error" # RPC issue during sim
|
|
61
|
+
BROADCAST_FAILED = "broadcast_failed" # RPC rejected tx
|
|
62
|
+
|
|
63
|
+
# Pre-broadcast failures (kept for backward compat)
|
|
64
|
+
SIGNER_FAILED = "signer_failed" # Keystore/signer issue
|
|
65
|
+
NONCE_FAILED = "nonce_failed" # Couldn't reserve nonce
|
|
66
|
+
SIGN_FAILED = "sign_failed" # Signing error
|
|
67
|
+
NONCE_CONSUMED = "nonce_consumed" # Nonce used elsewhere
|
|
68
|
+
|
|
69
|
+
# Superseded (replaced by new attempt)
|
|
70
|
+
SUPERSEDED = "superseded"
|
|
71
|
+
|
|
72
|
+
# Reorg (terminal - we don't retry reorged intents)
|
|
73
|
+
REORGED = "reorged"
|
|
74
|
+
|
|
75
|
+
# Pre-intent exceptions (no intent created yet)
|
|
76
|
+
CHECK_EXCEPTION = "check_exception" # job.check() crashed
|
|
77
|
+
BUILD_TX_EXCEPTION = "build_tx_exception" # job.build_tx() crashed
|
|
78
|
+
|
|
79
|
+
# Fallback
|
|
80
|
+
UNKNOWN = "unknown"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BrawnyError(Exception):
|
|
84
|
+
"""Base exception for all brawny errors."""
|
|
85
|
+
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConfigError(BrawnyError):
|
|
90
|
+
"""Configuration error."""
|
|
91
|
+
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DatabaseError(BrawnyError):
|
|
96
|
+
"""Database operation error."""
|
|
97
|
+
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NonceError(BrawnyError):
|
|
102
|
+
"""Nonce management error."""
|
|
103
|
+
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class NonceReservationError(NonceError):
|
|
108
|
+
"""Failed to reserve a nonce."""
|
|
109
|
+
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class IntentError(BrawnyError):
|
|
114
|
+
"""Transaction intent error."""
|
|
115
|
+
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RetriableExecutionError(BrawnyError):
|
|
120
|
+
"""Temporary execution error that should be retried with backoff.
|
|
121
|
+
|
|
122
|
+
Used when execution cannot proceed temporarily (e.g., no gas quote available)
|
|
123
|
+
but may succeed after a backoff period.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class JobError(BrawnyError):
|
|
130
|
+
"""Job execution error."""
|
|
131
|
+
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ReorgError(BrawnyError):
|
|
136
|
+
"""Blockchain reorganization error."""
|
|
137
|
+
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class KeystoreError(BrawnyError):
|
|
142
|
+
"""Keystore or signing error."""
|
|
143
|
+
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ABIResolutionError(BrawnyError):
|
|
148
|
+
"""ABI resolution failed."""
|
|
149
|
+
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ReceiptRequiredError(BrawnyError):
|
|
154
|
+
"""Attempted to access receipt-only features without a receipt."""
|
|
155
|
+
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class EventNotFoundError(BrawnyError):
|
|
160
|
+
"""Event not found in receipt logs."""
|
|
161
|
+
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class StateChangingCallError(BrawnyError):
|
|
166
|
+
"""Attempted to call a state-changing function in a read-only context."""
|
|
167
|
+
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class CircuitBreakerOpenError(BrawnyError):
|
|
172
|
+
"""All RPC endpoints are unhealthy."""
|
|
173
|
+
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class DatabaseCircuitBreakerOpenError(BrawnyError):
|
|
178
|
+
"""Database circuit breaker is open."""
|
|
179
|
+
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SimulationReverted(BrawnyError):
|
|
184
|
+
"""Transaction would revert on-chain. Permanent failure - do not retry or broadcast."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, reason: str) -> None:
|
|
187
|
+
super().__init__(reason)
|
|
188
|
+
self.reason = reason
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SimulationNetworkError(BrawnyError):
|
|
192
|
+
"""Network/RPC error during simulation. Transient - may retry."""
|
|
193
|
+
|
|
194
|
+
pass
|
brawny/model/events.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Event decoding types and helpers.
|
|
2
|
+
|
|
3
|
+
DecodedEvent is a frozen dataclass representing a single decoded log event.
|
|
4
|
+
Helper functions are pure functions, not methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DecodedEvent:
|
|
16
|
+
"""Single decoded log event. Immutable.
|
|
17
|
+
|
|
18
|
+
Use Mapping[str, Any] for args to ensure immutability.
|
|
19
|
+
The args are wrapped with MappingProxyType at construction.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
address: str
|
|
23
|
+
event_name: str
|
|
24
|
+
args: Mapping[str, Any] # Immutable - use MappingProxyType when constructing
|
|
25
|
+
log_index: int
|
|
26
|
+
tx_hash: str
|
|
27
|
+
block_number: int
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def create(
|
|
31
|
+
cls,
|
|
32
|
+
address: str,
|
|
33
|
+
event_name: str,
|
|
34
|
+
args: dict[str, Any],
|
|
35
|
+
log_index: int,
|
|
36
|
+
tx_hash: str,
|
|
37
|
+
block_number: int,
|
|
38
|
+
) -> DecodedEvent:
|
|
39
|
+
"""Create a DecodedEvent with immutable args.
|
|
40
|
+
|
|
41
|
+
Wraps the args dict in MappingProxyType to ensure immutability.
|
|
42
|
+
"""
|
|
43
|
+
return cls(
|
|
44
|
+
address=address,
|
|
45
|
+
event_name=event_name,
|
|
46
|
+
args=MappingProxyType(args),
|
|
47
|
+
log_index=log_index,
|
|
48
|
+
tx_hash=tx_hash,
|
|
49
|
+
block_number=block_number,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_event(events: list[DecodedEvent], event_name: str) -> DecodedEvent | None:
|
|
54
|
+
"""Find the first event matching the given name.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
events: List of decoded events
|
|
58
|
+
event_name: Name of event to find
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
First matching event, or None if not found
|
|
62
|
+
"""
|
|
63
|
+
for event in events:
|
|
64
|
+
if event.event_name == event_name:
|
|
65
|
+
return event
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def events_by_name(events: list[DecodedEvent], event_name: str) -> list[DecodedEvent]:
|
|
70
|
+
"""Get all events matching the given name.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
events: List of decoded events
|
|
74
|
+
event_name: Name of events to find
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of matching events (may be empty)
|
|
78
|
+
"""
|
|
79
|
+
return [e for e in events if e.event_name == event_name]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def events_by_address(events: list[DecodedEvent], address: str) -> list[DecodedEvent]:
|
|
83
|
+
"""Get all events emitted by the given address.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
events: List of decoded events
|
|
87
|
+
address: Contract address (case-insensitive comparison)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of matching events (may be empty)
|
|
91
|
+
"""
|
|
92
|
+
addr_lower = address.lower()
|
|
93
|
+
return [e for e in events if e.address.lower() == addr_lower]
|
brawny/model/startup.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Startup diagnostic message types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class StartupMessage:
|
|
11
|
+
"""Startup diagnostic message for human-readable display.
|
|
12
|
+
|
|
13
|
+
Used to collect warnings and errors during startup for display
|
|
14
|
+
before the "--- Starting brawny ---" banner.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
level: Literal["warning", "error"]
|
|
18
|
+
code: str
|
|
19
|
+
message: str
|
|
20
|
+
fix: str | None = None # Actionable fix suggestion
|